Compare commits

..

19 Commits

Author SHA1 Message Date
Talmaj Marinc
c6d1403ac7 Switch to oauth. 2026-07-13 00:26:33 +02:00
Talmaj Marinc
be7aca3d5a Remove flickering caused by shortly displaying raw backend error in the frontend for download row. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
8fb6f40d16 Add correct notificatino for gated models with accept license link. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
645b2b53c2 Apply AI comments. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
5c364b7e0b Fix unit tests. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
b21183ce09 Add a browser test for clearning of all downloads. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
78109c66c2 Add persistend clearing of downloads and deletion of single downloads. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
b78e5a8c70 Fix escaped model path rendering in modal. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
3ae3fbe83d Comment out allow-all extensions option in download model modal. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
9c62159d52 Fix wide/whole row allow any extension bug. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
dffc81524c Fix clickthrough Modal bug. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
c8873a1cb2 Rename modelManage.title to Downloads, increase --sidebar-default-floating-width so that it fits in one line. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
8c8a5e9212 Fix canceled display of downloads. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
bd303074be Rename downloadCredentials to hostCredentials 2026-07-13 00:26:32 +02:00
Talmaj Marinc
d57a243e97 Add some browser tests. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
3ff5312fc5 Increase coverage tests. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
81012d28a4 Cleanup docstrings. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
897caaf90e Apply fixes for AI discovered bugs. 2026-07-13 00:26:32 +02:00
Talmaj Marinc
0f6e7f8770 Add DownloadManager. 2026-07-13 00:26:29 +02:00
112 changed files with 5850 additions and 3054 deletions

View File

@@ -255,10 +255,9 @@
Shifting visually with `top` corrects the appearance without changing the
inline-block's bounding box, so button/badge sizes are unaffected. */
@utility ppformula-text-center {
/* display: inline-block; */
/* position: relative; */
/* top: 0.1em; */
--ppformula-text-center: 1;
display: inline-block;
position: relative;
top: 0.1em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -276,7 +276,6 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -11,47 +11,39 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
return this.node.getByRole('button', { name: /show menu/i })
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: 'Record', exact: true })
return this.node.getByRole('button', { name: /start recording/i })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get recordingMenuButton(): Locator {
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: 'BG Image' })
return this.node.getByRole('button', { name: /upload background image/i })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: 'Remove BG' })
return this.node.getByRole('button', { name: /remove background image/i })
}
get panoramaModeButton(): Locator {
@@ -62,10 +54,6 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -75,15 +63,11 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.menuPanel.getByRole('button', { name, exact: true })
return this.node.getByText(name, { exact: true })
}
get gizmoToggleButton(): Locator {
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
return this.node.getByRole('button', { name: 'Gizmo' })
}
get gizmoTranslateButton(): Locator {
@@ -99,17 +83,13 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset', exact: true })
return this.node.getByRole('button', { name: 'Reset Transform' })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,12 +47,10 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -255,7 +253,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
test('Recording controls show stop/export/clear buttons after a recording', async ({
comfyPage,
load3d
}) => {
@@ -267,25 +265,20 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,250 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type {
DownloadStatus,
EnqueueResponse,
ProviderAuthStatus
} from '@/platform/modelManager/types'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const DOWNLOADS_ROUTE = /\/api\/download$/
const ENQUEUE_ROUTE = /\/api\/download\/enqueue$/
const AUTH_ROUTE = /\/api\/download\/auth$/
const CLEAR_ROUTE = /\/api\/download\/clear$/
const DOWNLOAD_ID = 'e2e-download-1'
const MODEL_URL =
'https://huggingface.co/e2e/test/resolve/main/model.safetensors'
const MODEL_ID = 'checkpoints/e2e-test-model.safetensors'
function makeDownloadStatus(
overrides: Partial<DownloadStatus> = {}
): DownloadStatus {
const now = Math.floor(Date.now() / 1000)
return {
download_id: DOWNLOAD_ID,
model_id: MODEL_ID,
url: MODEL_URL,
status: 'queued',
priority: 0,
total_bytes: null,
bytes_done: 0,
progress: null,
speed_bps: null,
eta_seconds: null,
segments: null,
error: null,
created_at: now,
updated_at: now,
...overrides
}
}
async function enableServerSideModelDownloads(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
server_side_model_downloads: true
}
})
}
/** Keeps GET /download consistent so the 5s stale-poll can't wipe test state. */
async function mockDownloadsList(
page: Page,
getDownloads: () => DownloadStatus[]
) {
await page.route(DOWNLOADS_ROUTE, async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ downloads: getDownloads() }))
})
}
function modelDownloaderTabButton(comfyPage: ComfyPage) {
return comfyPage.page.locator('.model-manager-tab-button')
}
function modelDownloaderBadge(comfyPage: ComfyPage) {
return modelDownloaderTabButton(comfyPage).locator('.sidebar-icon-badge')
}
function modelDownloaderPanel(comfyPage: ComfyPage) {
return comfyPage.page.locator('.sidebar-content-container')
}
async function openModelDownloaderTab(comfyPage: ComfyPage) {
await modelDownloaderTabButton(comfyPage).click()
const panel = modelDownloaderPanel(comfyPage)
await expect(panel.getByText('Downloads', { exact: true })).toBeVisible()
// Toolbar buttons are only interactive while the tab panel is hovered
// (`group-hover/sidebar-tab`), so keep the cursor over it for later clicks.
await panel.hover()
}
test.describe('Model Downloader sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles({ checkpoints: [] })
// Isolate from any real downloads already tracked by the backend.
await mockDownloadsList(comfyPage.page, () => [])
await enableServerSideModelDownloads(comfyPage)
})
test('adds a model by URL and reflects live progress over the websocket', async ({
comfyPage,
getWebSocket
}) => {
let downloads: DownloadStatus[] = []
await mockDownloadsList(comfyPage.page, () => downloads)
await comfyPage.page.route(ENQUEUE_ROUTE, async (route) => {
downloads = [makeDownloadStatus()]
const response: EnqueueResponse = {
download_id: DOWNLOAD_ID,
accepted: true
}
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await openModelDownloaderTab(comfyPage)
const panel = modelDownloaderPanel(comfyPage)
await panel.getByTitle('Add model').click()
const addDialog = comfyPage.page.getByRole('dialog')
await expect(addDialog.getByText('Add model')).toBeVisible()
await addDialog.getByLabel('URL').fill(MODEL_URL)
await expect(addDialog.getByLabel('Filename')).toHaveValue(
'model.safetensors'
)
await addDialog.getByLabel('Filename').fill('e2e-test-model.safetensors')
await addDialog.getByRole('combobox', { name: 'Select a folder' }).click()
await comfyPage.page
.getByRole('option', { name: 'Checkpoints', exact: true })
.click()
await addDialog
.getByRole('button', { name: 'Download', exact: true })
.click()
await expect(addDialog).toBeHidden()
await expect(panel.getByText('e2e-test-model.safetensors')).toBeVisible()
await expect(panel.getByText('Queued', { exact: true })).toBeVisible()
await expect(modelDownloaderBadge(comfyPage)).toHaveText('1')
const ws = await getWebSocket()
function sendProgress(overrides: Partial<DownloadStatus>) {
const payload = makeDownloadStatus(overrides)
downloads = [payload]
ws.send(JSON.stringify({ type: 'download_progress', data: payload }))
}
sendProgress({
status: 'active',
progress: 0.4,
bytes_done: 400,
total_bytes: 1000,
speed_bps: 500_000
})
await expect(panel.getByText('Downloading', { exact: true })).toBeVisible()
await expect(panel.getByText(/40%/)).toBeVisible()
await expect(modelDownloaderBadge(comfyPage)).toHaveText('1')
sendProgress({
status: 'completed',
progress: 1,
bytes_done: 1000,
total_bytes: 1000,
speed_bps: null,
eta_seconds: null
})
await expect(panel.getByText('Completed', { exact: true })).toBeVisible()
await expect(panel.getByText('History', { exact: true })).toBeVisible()
await expect(modelDownloaderBadge(comfyPage)).toHaveCount(0)
})
test('clears history and the cleared rows stay gone after reopening the tab', async ({
comfyPage
}) => {
let downloads: DownloadStatus[] = [
makeDownloadStatus({ status: 'completed', progress: 1, bytes_done: 1000 })
]
await mockDownloadsList(comfyPage.page, () => downloads)
await comfyPage.page.route(CLEAR_ROUTE, async (route) => {
const count = downloads.length
downloads = []
await route.fulfill(jsonRoute({ deleted: count }))
})
await openModelDownloaderTab(comfyPage)
const panel = modelDownloaderPanel(comfyPage)
await expect(panel.getByText('History', { exact: true })).toBeVisible()
await expect(panel.getByText('e2e-test-model.safetensors')).toBeVisible()
await panel.getByRole('button', { name: 'Clear history' }).click()
await expect(panel.getByText('No downloads yet')).toBeVisible()
// Reopening the tab re-runs hydrate() -> GET /api/download. The bug was
// that the cleared row reappeared here; the persisted delete must prevent
// the backend list from returning it again.
await modelDownloaderTabButton(comfyPage).click()
await openModelDownloaderTab(comfyPage)
await expect(
modelDownloaderPanel(comfyPage).getByText('No downloads yet')
).toBeVisible()
await expect(
modelDownloaderPanel(comfyPage).getByText('e2e-test-model.safetensors')
).toBeHidden()
})
test('shows per-provider download auth status in the access dialog', async ({
comfyPage
}) => {
const providers: ProviderAuthStatus[] = [
{
provider: 'huggingface',
env_key_present: true,
logged_in: false,
login_in_progress: false
},
{
provider: 'civitai',
env_key_present: false,
logged_in: false,
login_in_progress: false
}
]
await comfyPage.page.route(AUTH_ROUTE, async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ providers }))
})
await openModelDownloaderTab(comfyPage)
const panel = modelDownloaderPanel(comfyPage)
await panel.getByTitle('Download access').click()
const dialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Download access' })
await expect(dialog.getByText('HuggingFace')).toBeVisible()
await expect(dialog.getByText('API key set on the server')).toBeVisible()
// Civitai is unauthenticated; on this loopback deployment the OAuth login
// action is offered.
await expect(dialog.getByRole('button', { name: 'Log in' })).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'How to add an API key' })
).toBeVisible()
})
})

View File

@@ -128,9 +128,9 @@ function renderLoad3D(options: RenderOptions = {}) {
name: 'AnimationControls',
template: '<div data-testid="animation-controls" />'
},
RecordMenuControl: {
name: 'RecordMenuControl',
template: '<div data-testid="record-menu-control" />'
RecordingControls: {
name: 'RecordingControls',
template: '<div data-testid="recording-controls" />'
},
ViewerControls: {
name: 'ViewerControls',
@@ -232,16 +232,14 @@ describe('Load3D', () => {
})
describe('recording controls', () => {
it('renders the record control in regular (non-preview) mode', () => {
it('renders RecordingControls in regular (non-preview) mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
expect(screen.getByTestId('record-menu-control')).toBeInTheDocument()
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
})
it('hides the record control in preview mode', () => {
it('hides RecordingControls in preview mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
expect(
screen.queryByTestId('record-menu-control')
).not.toBeInTheDocument()
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
})
})

View File

@@ -15,39 +15,25 @@
:is-preview="isPreview"
/>
<div class="pointer-events-none absolute top-0 left-0 size-full">
<Load3DMenuBar
<Load3DControls
v-model:scene-config="sceneConfig"
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:can-fit-to-viewer="canFitToViewer"
:can-center-camera-on-model="canCenterCameraOnModel"
:node="node as LGraphNode"
:enable-viewer="enable3DViewer"
:can-use-recording="canUseRecording && !isPreview"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@update-hdri-file="handleHDRIFileUpdate"
@export-model="handleExportModel"
@fit-to-viewer="handleFitToViewer"
@center-camera="handleCenterCameraOnModel"
@update-hdri-file="handleHDRIFileUpdate"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -60,6 +46,59 @@
@seek="handleSeek"
/>
</div>
<div
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.fitToViewer')"
@click="handleFitToViewer"
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
</div>
</div>
</template>
@@ -67,9 +106,12 @@
import { computed, onMounted, ref } from 'vue'
import type { Ref } from 'vue'
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -150,11 +192,11 @@ const {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleFitToViewer,
handleCenterCameraOnModel,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -1,242 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeSceneConfig(): SceneConfig {
return {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
}
}
function makeModelConfig(): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
function makeCameraConfig(): CameraConfig {
return { cameraType: 'perspective', fov: 75 }
}
function makeLightConfig(): LightConfig {
return {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
}
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
function renderMenuBar(overrides: RenderProps = {}) {
const result = render(Load3DMenuBar, {
props: {
sceneConfig: makeSceneConfig(),
modelConfig: makeModelConfig(),
cameraConfig: makeCameraConfig(),
lightConfig: makeLightConfig(),
...overrides
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...result, user: userEvent.setup() }
}
async function selectCategory(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: label }))
}
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Scene/ }))
}
describe('Load3DMenuBar', () => {
it('shows scene controls by default', () => {
renderMenuBar()
expect(
screen.getByRole('button', { name: 'Show grid' })
).toBeInTheDocument()
})
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
const sceneConfig = makeSceneConfig()
const { user } = renderMenuBar({ sceneConfig })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(sceneConfig.showGrid).toBe(false)
})
it('emits fitToViewer when the fit button is clicked', async () => {
const onFitToViewer = vi.fn()
const { user } = renderMenuBar({ onFitToViewer })
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
expect(onFitToViewer).toHaveBeenCalledOnce()
})
it('emits centerCamera when the center button is clicked', async () => {
const onCenterCamera = vi.fn()
const { user } = renderMenuBar({ onCenterCamera })
await user.click(
screen.getByRole('button', { name: 'Center Camera on Model' })
)
expect(onCenterCamera).toHaveBeenCalledOnce()
})
it('hides the center button when canCenterCameraOnModel is false', () => {
renderMenuBar({ canCenterCameraOnModel: false })
expect(
screen.queryByRole('button', { name: 'Center Camera on Model' })
).not.toBeInTheDocument()
})
it('toggles the gizmo and reveals the mode controls inline', async () => {
const onToggleGizmo = vi.fn()
const onSetGizmoMode = vi.fn()
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
await selectCategory(user, 'Gizmo')
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
await user.click(gizmoButtons[gizmoButtons.length - 1])
expect(onToggleGizmo).toHaveBeenCalledWith(true)
await user.click(screen.getByRole('button', { name: 'Rotate' }))
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
})
it('shows the hdri upload inline without an extra popover', async () => {
const { user } = renderMenuBar()
await selectCategory(user, 'HDRI')
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
})
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
const onUpdateHdriFile = vi.fn()
const lightConfig = makeLightConfig()
lightConfig.hdri = {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 1
}
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
await selectCategory(user, 'HDRI')
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('emits startRecording when the record button is clicked', async () => {
const onStartRecording = vi.fn()
const { user } = renderMenuBar({ onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('forwards exportRecording from the recording menu once a recording exists', async () => {
const onExportRecording = vi.fn()
const { user } = renderMenuBar({
hasRecording: true,
isRecording: false,
onExportRecording
})
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('omits the gizmo category when canUseGizmo is false', async () => {
const { user } = renderMenuBar({ canUseGizmo: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Gizmo' })
).not.toBeInTheDocument()
})
it('switches to the camera category and shows its controls', async () => {
const { user } = renderMenuBar()
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: 'Camera' }))
expect(
screen.getByRole('button', { name: 'Perspective' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
it('omits the light category when canUseLighting is false', async () => {
const { user } = renderMenuBar({ canUseLighting: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
})
it('hides scene controls when sceneConfig is undefined', () => {
renderMenuBar({ sceneConfig: undefined })
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
})

View File

@@ -1,337 +0,0 @@
<template>
<div class="pointer-events-none absolute inset-0 flex flex-col">
<div
ref="topBarRef"
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<Popover v-model:open="catMenuOpen">
<PopoverTrigger as-child>
<button
:class="chipClass"
type="button"
data-testid="load3d-category-menu"
>
{{ activeLabel }}
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="c in categoryDefs"
:key="c.key"
type="button"
:class="
cn(
rowClass,
activeCategory === c.key && 'bg-button-active-surface'
)
"
@click="selectCategory(c.key)"
>
{{ c.label }}
</button>
</PopoverContent>
</Popover>
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
<SceneMenuGroup
v-if="activeCategory === 'scene' && sceneConfig"
v-model:config="sceneConfig"
v-model:fov="cameraFov"
:compact
:can-use-background-image="canUseBackgroundImage"
:hdri-active="hdriActive"
@update-background-image="emit('updateBackgroundImage', $event)"
/>
<ModelMenuGroup
v-else-if="activeCategory === 'model' && modelConfig"
v-model:config="modelConfig"
:compact
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
/>
<CameraMenuGroup
v-else-if="activeCategory === 'camera' && cameraConfig"
v-model:config="cameraConfig"
:compact
/>
<LightMenuGroup
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
v-model:config="lightConfig"
:compact
:is-original-material="isOriginalMaterial"
/>
<HdriMenuGroup
v-else-if="activeCategory === 'hdri' && lightConfig"
v-model:config="lightConfig"
:compact
:scene-has-image="sceneHasImage"
@update-hdri-file="emit('updateHdriFile', $event)"
/>
<GizmoMenuGroup
v-else-if="activeCategory === 'gizmo' && modelConfig"
v-model:config="modelConfig"
:compact
@toggle-gizmo="emit('toggleGizmo', $event)"
@set-gizmo-mode="emit('setGizmoMode', $event)"
@reset-gizmo-transform="emit('resetGizmoTransform')"
/>
</div>
<div
:class="
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
"
/>
<div
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<div class="flex items-center gap-1">
<RecordMenuControl
v-if="canUseRecording"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:compact
@start-recording="emit('startRecording')"
@stop-recording="emit('stopRecording')"
@export-recording="emit('exportRecording')"
@clear-recording="emit('clearRecording')"
/>
</div>
<div class="flex items-center gap-1">
<ViewerControls
v-if="enableViewer && node"
:node="node as LGraphNode"
/>
<button
v-if="canFitToViewer"
v-tooltip.top="tip(t('load3d.fitToViewer'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.fitToViewer')"
@click="emit('fitToViewer')"
>
<i class="icon-[lucide--scan] size-4" />
</button>
<button
v-if="canCenterCameraOnModel"
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.centerCameraOnModel')"
@click="emit('centerCamera')"
>
<i class="icon-[lucide--crosshair] size-4" />
</button>
<Popover v-if="canExport" v-model:open="exportOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.export'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.export')"
>
<i class="icon-[lucide--download] size-4" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
:side-offset="8"
:class="panelClass"
>
<button
v-for="format in exportFormats"
:key="format.value"
type="button"
:class="rowClass"
@click="onExport(format.value)"
>
{{ format.label }}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { PopoverTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
import {
chipClass,
iconBtnClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
CameraConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const {
canUseLighting = true,
canUseHdri = true,
canUseGizmo = true,
canExport = true,
canUseBackgroundImage = true,
canFitToViewer = true,
canCenterCameraOnModel = true,
canUseRecording = true,
enableViewer = false,
node = null,
materialModes = ['original', 'clay', 'normal', 'wireframe'],
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseLighting?: boolean
canUseHdri?: boolean
canUseGizmo?: boolean
canExport?: boolean
canUseBackgroundImage?: boolean
canFitToViewer?: boolean
canCenterCameraOnModel?: boolean
canUseRecording?: boolean
enableViewer?: boolean
node?: LGraphNode | null
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateHdriFile', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'fitToViewer'): void
(e: 'centerCamera'): void
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const categoryDefs = computed(() =>
[
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
{
key: 'model',
label: t('load3d.model3d'),
show: !!modelConfig.value
},
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
{
key: 'light',
label: t('load3d.light'),
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
},
{
key: 'hdri',
label: t('load3d.hdri.label'),
show: canUseHdri && !!lightConfig.value
},
{
key: 'gizmo',
label: t('load3d.gizmo.label'),
show: canUseGizmo && !!modelConfig.value
}
].filter((c) => c.show)
)
const activeCategory = ref('scene')
const activeLabel = computed(
() =>
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
)
watch(categoryDefs, (defs) => {
if (!defs.some((c) => c.key === activeCategory.value)) {
activeCategory.value = defs[0]?.key ?? 'scene'
}
})
const catMenuOpen = ref(false)
const exportOpen = ref(false)
const sceneHasImage = computed(
() =>
!!sceneConfig.value?.backgroundImage &&
sceneConfig.value.backgroundImage !== ''
)
const hdriActive = computed(
() =>
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
)
const isOriginalMaterial = computed(
() => modelConfig.value?.materialMode === 'original'
)
const cameraFov = computed({
get: () => cameraConfig.value?.fov ?? 0,
set: (value) => {
if (cameraConfig.value) cameraConfig.value.fov = value
}
})
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const topBarRef = ref<HTMLElement | null>(null)
const { width: topW } = useElementSize(topBarRef)
const compactWidthThreshold = 480
const compact = computed(
() => topW.value > 0 && topW.value < compactWidthThreshold
)
function selectCategory(key: string) {
activeCategory.value = key
catMenuOpen.value = false
}
function onExport(format: string) {
emit('exportModel', format)
exportOpen.value = false
}
</script>

View File

@@ -0,0 +1,205 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
startRecording: 'Start recording',
stopRecording: 'Stop recording',
exportRecording: 'Export recording',
clearRecording: 'Clear recording'
}
}
}
})
type RenderOpts = {
hasRecording?: boolean
isRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderComponent(opts: RenderOpts = {}) {
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
const isRecording = ref<boolean>(opts.isRecording ?? false)
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
const utils = render(RecordingControls, {
props: {
hasRecording: hasRecording.value,
'onUpdate:hasRecording': (v: boolean | undefined) => {
if (v !== undefined) hasRecording.value = v
},
isRecording: isRecording.value,
'onUpdate:isRecording': (v: boolean | undefined) => {
if (v !== undefined) isRecording.value = v
},
recordingDuration: recordingDuration.value,
'onUpdate:recordingDuration': (v: number | undefined) => {
if (v !== undefined) recordingDuration.value = v
},
onStartRecording: opts.onStartRecording,
onStopRecording: opts.onStopRecording,
onExportRecording: opts.onExportRecording,
onClearRecording: opts.onClearRecording
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, user: userEvent.setup() }
}
describe('RecordingControls', () => {
it('shows the start-recording button initially', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Start recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Stop recording' })
).not.toBeInTheDocument()
})
it('shows the stop-recording button while recording is in progress', () => {
renderComponent({ isRecording: true })
expect(
screen.getByRole('button', { name: 'Stop recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Start recording' })
).not.toBeInTheDocument()
})
it('emits startRecording when the button is clicked from a stopped state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: false,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Start recording' }))
expect(onStartRecording).toHaveBeenCalledOnce()
expect(onStopRecording).not.toHaveBeenCalled()
})
it('emits stopRecording when the button is clicked from a recording state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: true,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
expect(onStartRecording).not.toHaveBeenCalled()
})
it('hides the export and clear buttons when there is no recording', () => {
renderComponent({ hasRecording: false, isRecording: false })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('shows the export and clear buttons once a recording exists', () => {
renderComponent({ hasRecording: true, isRecording: false })
expect(
screen.getByRole('button', { name: 'Export recording' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Clear recording' })
).toBeInTheDocument()
})
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
renderComponent({ hasRecording: true, isRecording: true })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('emits exportRecording and clearRecording from their respective buttons', async () => {
const onExportRecording = vi.fn()
const onClearRecording = vi.fn()
const { user } = renderComponent({
hasRecording: true,
isRecording: false,
onExportRecording,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Export recording' }))
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('renders the formatted duration as MM:SS once a recording exists', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 75
})
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
'01:15'
)
})
it('hides the duration display while a recording is in progress', () => {
renderComponent({
hasRecording: true,
isRecording: true,
recordingDuration: 30
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
it('hides the duration display when recordingDuration is zero', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 0
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,126 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
value: isRecording
? $t('load3d.stopRecording')
: $t('load3d.startRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn(
'rounded-full',
isRecording && 'recording-button-blink text-red-500'
)
"
:aria-label="
isRecording ? $t('load3d.stopRecording') : $t('load3d.startRecording')
"
@click="toggleRecording"
>
<i
:class="[
'pi',
isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-base-foreground'
]"
/>
</Button>
<Button
v-if="hasRecording && !isRecording"
v-tooltip.right="{
value: $t('load3d.exportRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording"
>
<i class="pi pi-download text-lg text-base-foreground" />
</Button>
<Button
v-if="hasRecording && !isRecording"
v-tooltip.right="{
value: $t('load3d.clearRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording"
>
<i class="pi pi-trash text-lg text-base-foreground" />
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-base-foreground"
data-testid="load3d-recording-duration"
>
{{ formatDuration(recordingDuration) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const hasRecording = defineModel<boolean>('hasRecording')
const isRecording = defineModel<boolean>('isRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
function toggleRecording() {
if (isRecording.value) {
emit('stopRecording')
} else {
emit('startRecording')
}
}
function handleExportRecording() {
emit('exportRecording')
}
function handleClearRecording() {
emit('clearRecording')
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.recording-button-blink {
animation: blink 1s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View File

@@ -1,47 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<CameraConfig> = {}): CameraConfig {
return { cameraType: 'perspective', fov: 75, ...overrides }
}
function renderGroup(config = makeConfig()) {
const result = render(CameraMenuGroup, {
props: { config },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup(), config }
}
describe('CameraMenuGroup', () => {
it('switches the projection type', async () => {
const { user, config } = renderGroup()
await user.click(screen.getByRole('button', { name: 'Perspective' }))
expect(config.cameraType).toBe('orthographic')
})
it('offers the FOV control only for a perspective camera', () => {
renderGroup(makeConfig({ cameraType: 'orthographic' }))
expect(
screen.queryByRole('button', { name: 'FOV' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Orthographic' })
).toBeInTheDocument()
})
})

View File

@@ -1,86 +0,0 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.switchProjection'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.switchProjection') : undefined"
@click="switchCamera"
>
<i class="icon-[lucide--camera] size-4" />
<span v-if="!compact">{{ cameraTypeLabel }}</span>
</button>
<Popover v-if="isPerspective">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
>
<i class="icon-[lucide--focus] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{ t('load3d.fov') }}</span>
<Slider
:model-value="[fov]"
:min="10"
:max="150"
:step="1"
class="w-full"
@update:model-value="setFov"
/>
</div>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const config = defineModel<CameraConfig>('config')
const { t } = useI18n()
const cameraType = computed(() => config.value?.cameraType)
const isPerspective = computed(() => cameraType.value === 'perspective')
const cameraTypeLabel = computed(() =>
cameraType.value ? t(`load3d.cameraType.${cameraType.value}`) : ''
)
const fov = computed(() => config.value?.fov ?? 0)
function switchCamera() {
if (!config.value) return
config.value.cameraType =
config.value.cameraType === 'perspective' ? 'orthographic' : 'perspective'
}
function setFov(value?: number[]) {
if (config.value && value?.length) config.value.fov = value[0]
}
</script>

View File

@@ -1,72 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(enabled: boolean): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
type Props = {
config: ModelConfig
onToggleGizmo?: (enabled: boolean) => void
onSetGizmoMode?: (mode: string) => void
}
function renderGroup(props: Props) {
const result = render(GizmoMenuGroup, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('GizmoMenuGroup', () => {
it('enables the gizmo and reveals the mode controls', async () => {
const config = makeConfig(false)
const onToggleGizmo = vi.fn()
const { user } = renderGroup({ config, onToggleGizmo })
expect(
screen.queryByRole('button', { name: 'Rotate' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(onToggleGizmo).toHaveBeenCalledWith(true)
expect(config.gizmo?.enabled).toBe(true)
expect(screen.getByRole('button', { name: 'Rotate' })).toBeInTheDocument()
})
it('sets the transform mode', async () => {
const config = makeConfig(true)
const onSetGizmoMode = vi.fn()
const { user } = renderGroup({ config, onSetGizmoMode })
await user.click(screen.getByRole('button', { name: 'Rotate' }))
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
expect(config.gizmo?.mode).toBe('rotate')
})
})

View File

@@ -1,105 +0,0 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.toggle'))"
:class="actionClass(gizmoEnabled)"
:aria-pressed="gizmoEnabled"
type="button"
:aria-label="compact ? t('load3d.gizmo.toggle') : undefined"
@click="toggleGizmo"
>
<i class="icon-[lucide--axis-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.toggle') }}</span>
</button>
<template v-if="gizmoEnabled">
<button
v-tooltip.bottom="tip(t('load3d.gizmo.translate'))"
:class="actionClass(gizmoMode === 'translate')"
:aria-pressed="gizmoMode === 'translate'"
type="button"
:aria-label="compact ? t('load3d.gizmo.translate') : undefined"
@click="setGizmoMode('translate')"
>
<i class="icon-[lucide--move] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.translate') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.rotate'))"
:class="actionClass(gizmoMode === 'rotate')"
:aria-pressed="gizmoMode === 'rotate'"
type="button"
:aria-label="compact ? t('load3d.gizmo.rotate') : undefined"
@click="setGizmoMode('rotate')"
>
<i class="icon-[lucide--rotate-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.rotate') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.scale'))"
:class="actionClass(gizmoMode === 'scale')"
:aria-pressed="gizmoMode === 'scale'"
type="button"
:aria-label="compact ? t('load3d.gizmo.scale') : undefined"
@click="setGizmoMode('scale')"
>
<i class="icon-[lucide--scale-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.scale') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.reset'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.gizmo.reset') : undefined"
@click="resetGizmoTransform"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.reset') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
import type {
GizmoMode,
ModelConfig
} from '@/extensions/core/load3d/interfaces'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const config = defineModel<ModelConfig>('config')
const emit = defineEmits<{
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const { t } = useI18n()
const gizmoEnabled = computed(() => config.value?.gizmo?.enabled ?? false)
const gizmoMode = computed(() => config.value?.gizmo?.mode ?? 'translate')
function toggleGizmo() {
const gizmo = config.value?.gizmo
if (!gizmo) return
gizmo.enabled = !gizmo.enabled
emit('toggleGizmo', gizmo.enabled)
}
function setGizmoMode(mode: GizmoMode) {
const gizmo = config.value?.gizmo
if (!gizmo) return
gizmo.mode = mode
emit('setGizmoMode', mode)
}
function resetGizmoTransform() {
emit('resetGizmoTransform')
}
</script>

View File

@@ -1,74 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
import type {
HDRIConfig,
LightConfig
} from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(hdri?: Partial<HDRIConfig>): LightConfig {
return {
intensity: 5,
hdri: hdri
? {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1,
...hdri
}
: undefined
}
}
type Props = {
config?: LightConfig
sceneHasImage?: boolean
onUpdateHdriFile?: (file: File | null) => void
}
function renderGroup(props: Props = {}) {
const result = render(HdriMenuGroup, {
props: { config: makeConfig({}), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('HdriMenuGroup', () => {
it('shows the upload button when no HDRI is loaded', () => {
renderGroup()
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
})
it('hides the upload when a background image is set and no HDRI exists', () => {
renderGroup({ config: makeConfig({ hdriPath: '' }), sceneHasImage: true })
expect(
screen.queryByRole('button', { name: 'Upload' })
).not.toBeInTheDocument()
})
it('toggles enabled and forwards removal once a file is loaded', async () => {
const onUpdateHdriFile = vi.fn()
const config = makeConfig({ hdriPath: 'env.hdr', enabled: false })
const { user } = renderGroup({ config, onUpdateHdriFile })
await user.click(screen.getByRole('button', { name: 'HDRI' }))
expect(config.hdri?.enabled).toBe(true)
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
})

View File

@@ -1,130 +0,0 @@
<template>
<template v-if="!sceneHasImage || hdriPath">
<button
v-tooltip.bottom="
tip(
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
)
"
:class="actionClass(false)"
type="button"
:aria-label="
compact
? hdriPath
? t('load3d.hdri.changeFile')
: t('load3d.hdri.uploadFile')
: undefined
"
@click="hdriFileRef?.click()"
>
<i class="icon-[lucide--upload] size-4" />
<span v-if="!compact">{{
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
}}</span>
</button>
<input
ref="hdriFileRef"
type="file"
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
class="pointer-events-none absolute size-0 opacity-0"
@change="onHdriFilePicked"
/>
</template>
<template v-if="hdriPath">
<button
v-tooltip.bottom="tip(t('load3d.hdri.label'))"
:class="actionClass(hdriEnabled)"
:aria-pressed="hdriEnabled"
type="button"
:aria-label="compact ? t('load3d.hdri.label') : undefined"
@click="toggleHdriEnabled"
>
<i class="icon-[lucide--globe] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.label') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.hdri.showAsBackground'))"
:class="actionClass(hdriShowAsBackground)"
:aria-pressed="hdriShowAsBackground"
type="button"
:aria-label="compact ? t('load3d.hdri.showAsBackground') : undefined"
@click="toggleHdriShowAsBackground"
>
<i class="icon-[lucide--image] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.showAsBackground') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.hdri.removeFile'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.hdri.removeFile') : undefined"
@click="removeHdri"
>
<i class="icon-[lucide--x] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.removeFile') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
import {
SUPPORTED_HDRI_EXTENSIONS,
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
const { compact = false, sceneHasImage = false } = defineProps<{
compact?: boolean
sceneHasImage?: boolean
}>()
const config = defineModel<LightConfig>('config')
const emit = defineEmits<{
(e: 'updateHdriFile', file: File | null): void
}>()
const { t } = useI18n()
const hdriPath = computed(() => config.value?.hdri?.hdriPath ?? '')
const hdriEnabled = computed(() => config.value?.hdri?.enabled ?? false)
const hdriShowAsBackground = computed(
() => config.value?.hdri?.showAsBackground ?? false
)
const hdriFileRef = ref<HTMLInputElement | null>(null)
function onHdriFilePicked(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 toggleHdriEnabled() {
const hdri = config.value?.hdri
if (hdri) hdri.enabled = !hdri.enabled
}
function toggleHdriShowAsBackground() {
const hdri = config.value?.hdri
if (hdri) hdri.showAsBackground = !hdri.showAsBackground
}
function removeHdri() {
emit('updateHdriFile', null)
}
</script>

View File

@@ -1,74 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const settingValues: Record<string, number> = {
'Comfy.Load3D.LightIntensityMinimum': 1,
'Comfy.Load3D.LightIntensityMaximum': 10,
'Comfy.Load3D.LightAdjustmentIncrement': 0.1
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => settingValues[key] })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderGroup(isOriginalMaterial: boolean) {
const config: LightConfig = { intensity: 5 }
return render(LightMenuGroup, {
props: { config, isOriginalMaterial },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
}
describe('LightMenuGroup', () => {
it('shows the intensity control for the original material', () => {
renderGroup(true)
expect(
screen.getByRole('button', { name: 'Intensity' })
).toBeInTheDocument()
})
it('explains intensity is unavailable for other materials', () => {
renderGroup(false)
expect(
screen.queryByRole('button', { name: 'Intensity' })
).not.toBeInTheDocument()
expect(screen.getByText('Original material only')).toBeInTheDocument()
})
it('drives HDRI intensity (0-5) when an HDRI environment is active', async () => {
const config: LightConfig = {
intensity: 5,
hdri: {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 2
}
}
render(LightMenuGroup, {
props: { config, isOriginalMaterial: true },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Intensity' }))
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '5')
expect(slider).toHaveAttribute('aria-valuenow', '2')
})
})

View File

@@ -1,105 +0,0 @@
<template>
<Popover v-if="isOriginalMaterial">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.intensity'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.intensity') : undefined"
>
<i class="icon-[lucide--sun] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.intensity') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{
t('load3d.lightIntensity')
}}</span>
<Slider
:model-value="[sliderValue]"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
class="w-full"
@update:model-value="onIntensityUpdate"
/>
</div>
</PopoverContent>
</Popover>
<span v-else class="px-2 text-sm text-muted">{{
t('load3d.menuBar.originalMaterialOnly')
}}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const { compact = false, isOriginalMaterial = false } = defineProps<{
compact?: boolean
isOriginalMaterial?: boolean
}>()
const config = defineModel<LightConfig>('config')
const { t } = useI18n()
const settingStore = useSettingStore()
const lightIntensityMinimum = settingStore.get(
'Comfy.Load3D.LightIntensityMinimum'
)
const lightIntensityMaximum = settingStore.get(
'Comfy.Load3D.LightIntensityMaximum'
)
const lightAdjustmentIncrement = settingStore.get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const usesHdriIntensity = computed(
() => !!config.value?.hdri?.hdriPath?.length && !!config.value?.hdri?.enabled
)
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(() =>
usesHdriIntensity.value
? (config.value?.hdri?.intensity ?? 1)
: (config.value?.intensity ?? lightIntensityMinimum)
)
function onIntensityUpdate(value?: number[]) {
if (!value?.length || !config.value) return
const next = value[0]
if (usesHdriIntensity.value) {
if (config.value.hdri) config.value.hdri.intensity = next
} else {
config.value.intensity = next
}
}
</script>

View File

@@ -1,69 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
...overrides
}
}
function renderGroup(
props: { config?: ModelConfig; hasSkeleton?: boolean } = {}
) {
const result = render(ModelMenuGroup, {
props: { config: makeConfig(), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('ModelMenuGroup', () => {
it('sets the up direction from the popover', async () => {
const config = makeConfig()
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Up Direction' }))
await user.click(screen.getByRole('button', { name: '+Y' }))
expect(config.upDirection).toBe('+y')
})
it('sets the material mode from the popover', async () => {
const config = makeConfig()
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Material' }))
await user.click(screen.getByRole('button', { name: 'Wireframe' }))
expect(config.materialMode).toBe('wireframe')
})
it('toggles the skeleton only when supported', async () => {
const config = makeConfig({ showSkeleton: false })
const { user, rerender } = renderGroup({ config, hasSkeleton: false })
expect(
screen.queryByRole('button', { name: 'Skeleton' })
).not.toBeInTheDocument()
await rerender({ config, hasSkeleton: true })
await user.click(screen.getByRole('button', { name: 'Skeleton' }))
expect(config.showSkeleton).toBe(true)
})
})

View File

@@ -1,135 +0,0 @@
<template>
<Popover>
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.upDirection'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.upDirection') : undefined"
>
<i class="icon-[lucide--move-3d] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.upDirection') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="d in upDirections"
:key="d"
type="button"
:class="cn(rowClass, upDirection === d && 'bg-button-active-surface')"
@click="setUpDirection(d)"
>
{{ d.toUpperCase() }}
</button>
</PopoverContent>
</Popover>
<Popover v-if="materialModes.length">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.material'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.material') : undefined"
>
<i class="icon-[lucide--box] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.material') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="m in materialModes"
:key="m"
type="button"
:class="cn(rowClass, materialMode === m && 'bg-button-active-surface')"
@click="setMaterialMode(m)"
>
{{ t(`load3d.materialModes.${m}`) }}
</button>
</PopoverContent>
</Popover>
<button
v-if="hasSkeleton"
v-tooltip.bottom="tip(t('load3d.menuBar.skeleton'))"
:class="actionClass(showSkeleton)"
:aria-pressed="showSkeleton"
type="button"
:aria-label="compact ? t('load3d.menuBar.skeleton') : undefined"
@click="toggleSkeleton"
>
<i class="icon-[lucide--bone] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.skeleton') }}</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import type {
MaterialMode,
ModelConfig,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const {
compact = false,
hasSkeleton = false,
materialModes = ['original', 'clay', 'normal', 'wireframe']
} = defineProps<{
compact?: boolean
hasSkeleton?: boolean
materialModes?: readonly MaterialMode[]
}>()
const config = defineModel<ModelConfig>('config')
const { t } = useI18n()
const upDirection = computed(() => config.value?.upDirection)
const materialMode = computed(() => config.value?.materialMode)
const showSkeleton = computed(() => config.value?.showSkeleton ?? false)
const upDirections: UpDirection[] = [
'original',
'-x',
'+x',
'-y',
'+y',
'-z',
'+z'
]
function setUpDirection(direction: UpDirection) {
if (config.value) config.value.upDirection = direction
}
function setMaterialMode(mode: MaterialMode) {
if (config.value) config.value.materialMode = mode
}
function toggleSkeleton() {
if (config.value) config.value.showSkeleton = !config.value.showSkeleton
}
</script>

View File

@@ -1,138 +0,0 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
type Props = {
isRecording?: boolean
hasRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderControl(props: Props = {}) {
const result = render(RecordMenuControl, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
}
describe('RecordMenuControl', () => {
it('starts recording when idle', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({ isRecording: false, onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('stops recording when active', async () => {
const onStopRecording = vi.fn()
const { user } = renderControl({ isRecording: true, onStopRecording })
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
})
it('shows the recording duration once a recording exists', () => {
renderControl({
isRecording: false,
hasRecording: true,
recordingDuration: 65
})
expect(screen.getByText('01:05')).toBeInTheDocument()
})
it('downloads the recording from the menu', async () => {
const onExportRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onExportRecording
})
await openRecordingMenu(user)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('starts a new recording from the menu', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onStartRecording
})
await openRecordingMenu(user)
await user.click(
screen.getByRole('button', { name: 'Start New Recording' })
)
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('deletes the recording from the menu', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await openRecordingMenu(user)
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'Delete Recording'
})
)
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('deletes the recording via the chip dismiss button', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('shows only the stop control while recording is in progress', () => {
renderControl({ isRecording: true, hasRecording: true })
expect(
screen.queryByRole('button', {
name: 'Video recording of the scene [mp4]'
})
).not.toBeInTheDocument()
})
})

View File

@@ -1,141 +0,0 @@
<template>
<button
v-if="isRecording"
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
:class="chipClass"
type="button"
:aria-label="t('load3d.menuBar.stopRecording')"
@click="emit('stopRecording')"
>
<span class="size-2 animate-pulse rounded-full bg-red-500" />
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
</button>
<div
v-else-if="hasRecording"
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
>
<Popover v-model:open="menuOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
data-testid="load3d-recording-duration"
>
<i class="icon-[lucide--film] size-4" />
{{ formatDuration(recordingDuration ?? 0) }}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="downloadRecording"
>
<i class="icon-[lucide--download] size-4" />
{{ t('load3d.menuBar.downloadRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="startNewRecording"
>
<i class="icon-[lucide--video] size-4" />
{{ t('load3d.menuBar.startNewRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="deleteRecording"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ t('load3d.menuBar.deleteRecording') }}
</button>
</PopoverContent>
</Popover>
<button
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.deleteRecording')"
@click="emit('clearRecording')"
>
<i class="icon-[lucide--x] size-3.5" />
</button>
</div>
<button
v-else
v-tooltip.top="tip(t('load3d.menuBar.record'))"
:class="chipClass"
type="button"
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
@click="emit('startRecording')"
>
<i class="icon-[lucide--video] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
</button>
</template>
<script setup lang="ts">
import { PopoverTrigger } from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
chipClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const menuOpen = ref(false)
function downloadRecording() {
menuOpen.value = false
emit('exportRecording')
}
function startNewRecording() {
menuOpen.value = false
emit('startRecording')
}
function deleteRecording() {
menuOpen.value = false
emit('clearRecording')
}
function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>

View File

@@ -1,108 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<SceneConfig> = {}): SceneConfig {
return {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled',
...overrides
}
}
type Props = {
config?: SceneConfig
fov?: number
hdriActive?: boolean
canUseBackgroundImage?: boolean
onUpdateBackgroundImage?: (file: File | null) => void
}
function renderGroup(props: Props = {}) {
const result = render(SceneMenuGroup, {
props: { config: makeConfig(), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('SceneMenuGroup', () => {
it('toggles showGrid on the bound config', async () => {
const config = makeConfig({ showGrid: true })
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(config.showGrid).toBe(false)
})
it('hides background color and image controls while HDRI is active', () => {
renderGroup({ hdriActive: true })
expect(
screen.queryByRole('button', { name: 'BG Color' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'BG Image' })
).not.toBeInTheDocument()
})
it('hides the image upload when background images are not allowed', () => {
renderGroup({ canUseBackgroundImage: false })
expect(screen.getByRole('button', { name: 'BG Color' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'BG Image' })
).not.toBeInTheDocument()
})
it('shows panorama and remove once a background image exists', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderGroup({
config: makeConfig({ backgroundImage: 'bg.png' }),
onUpdateBackgroundImage
})
expect(screen.getByRole('button', { name: 'Panorama' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Remove BG' }))
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
})
it('exposes the FOV control while a panorama background is active', () => {
renderGroup({
config: makeConfig({
backgroundImage: 'bg.png',
backgroundRenderMode: 'panorama'
}),
fov: 75
})
expect(screen.getByRole('button', { name: 'FOV' })).toBeInTheDocument()
})
it('clears the file input so the same image can be re-picked', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderGroup({ onUpdateBackgroundImage })
const input = screen.getByTestId<HTMLInputElement>('scene-bg-image-input')
const file = new File(['x'], 'bg.png', { type: 'image/png' })
await user.upload(input, file)
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
expect(input.value).toBe('')
})
})

View File

@@ -1,190 +0,0 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.showGrid'))"
:class="actionClass(showGrid)"
:aria-pressed="showGrid"
type="button"
:aria-label="compact ? t('load3d.menuBar.showGrid') : undefined"
@click="toggleGrid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.showGrid') }}</span>
</button>
<template v-if="!hasImage && !hdriActive">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.bgColor'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.bgColor') : undefined"
@click="colorRef?.click()"
>
<i class="icon-[lucide--palette] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.bgColor') }}</span>
</button>
<input
ref="colorRef"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
:value="bgColor"
@input="setBackgroundColor"
/>
<template v-if="canUseBackgroundImage">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.bgImage'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.bgImage') : undefined"
@click="bgImageRef?.click()"
>
<i class="icon-[lucide--image] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.bgImage') }}</span>
</button>
<input
ref="bgImageRef"
type="file"
accept="image/*"
class="pointer-events-none absolute size-0 opacity-0"
data-testid="scene-bg-image-input"
@change="onBackgroundImagePicked"
/>
</template>
</template>
<template v-if="hasImage">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.panorama'))"
:class="actionClass(isPanorama)"
:aria-pressed="isPanorama"
type="button"
:aria-label="compact ? t('load3d.menuBar.panorama') : undefined"
@click="togglePanorama"
>
<i class="icon-[lucide--globe] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.panorama') }}</span>
</button>
<Popover v-if="isPanorama">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
>
<i class="icon-[lucide--focus] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{
t('load3d.fov')
}}</span>
<Slider
:model-value="[fovValue]"
:min="10"
:max="150"
:step="1"
class="w-full"
@update:model-value="setFov"
/>
</div>
</PopoverContent>
</Popover>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.removeBackground'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.removeBackground') : undefined"
@click="removeBackgroundImage"
>
<i class="icon-[lucide--x] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.removeBackground') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const {
compact = false,
canUseBackgroundImage = true,
hdriActive = false
} = defineProps<{
compact?: boolean
canUseBackgroundImage?: boolean
hdriActive?: boolean
}>()
const config = defineModel<SceneConfig>('config')
const fov = defineModel<number>('fov')
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()
const { t } = useI18n()
const showGrid = computed(() => config.value?.showGrid ?? false)
const bgColor = computed(() => config.value?.backgroundColor ?? '#000000')
const hasImage = computed(
() => !!config.value?.backgroundImage && config.value.backgroundImage !== ''
)
const isPanorama = computed(
() => config.value?.backgroundRenderMode === 'panorama'
)
const fovValue = computed(() => fov.value ?? 10)
const colorRef = ref<HTMLInputElement | null>(null)
const bgImageRef = ref<HTMLInputElement | null>(null)
function toggleGrid() {
if (config.value) config.value.showGrid = !config.value.showGrid
}
function setBackgroundColor(event: Event) {
if (config.value) {
config.value.backgroundColor = (event.target as HTMLInputElement).value
}
}
function onBackgroundImagePicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) emit('updateBackgroundImage', file)
}
function removeBackgroundImage() {
emit('updateBackgroundImage', null)
}
function togglePanorama() {
if (!config.value) return
config.value.backgroundRenderMode =
config.value.backgroundRenderMode === 'panorama' ? 'tiled' : 'panorama'
}
function setFov(value?: number[]) {
if (value?.length) fov.value = value[0]
}
</script>

View File

@@ -1,24 +0,0 @@
import { cn } from '@comfyorg/tailwind-utils'
export const chipClass =
'flex shrink-0 items-center gap-1.5 rounded-lg border-0 bg-interface-menu-surface px-2.5 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-button-active-surface focus-visible:ring-1 focus-visible:ring-ring'
export const iconBtnClass =
'flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground outline-none transition-colors hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring'
export const panelClass =
'w-48 max-h-80 overflow-y-auto flex flex-col gap-0.5 p-1.5 rounded-lg border-border-default bg-interface-menu-surface shadow-interface'
export const rowClass =
'flex w-full cursor-pointer items-center rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm text-base-foreground outline-none hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset'
export function actionClass(active: boolean) {
return cn(
'focus-visible:ring-ring flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1',
active && 'bg-button-active-surface'
)
}
export function tip(label: string) {
return { value: label, showDelay: 300 }
}

View File

@@ -243,7 +243,7 @@ onMounted(() => {
--sidebar-padding: 4px;
--sidebar-icon-size: 1rem;
--sidebar-default-floating-width: 48px;
--sidebar-default-floating-width: 50px;
--sidebar-default-connected-width: calc(
var(--sidebar-default-floating-width) + var(--sidebar-padding) * 2
);

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
import {
DialogContent,
injectDialogRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { DialogContentSize } from './dialog.variants'
import { dialogContentVariants } from './dialog.variants'
import { useModalPointerLock } from './useModalPointerLock'
const {
size,
@@ -23,6 +28,11 @@ const {
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(restProps, emits)
const dialogRootContext = injectDialogRootContext(null)
if (dialogRootContext?.modal.value) {
useModalPointerLock(() => dialogRootContext.open.value)
}
</script>
<template>

View File

@@ -0,0 +1,114 @@
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import {
SelectContent,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { defineComponent, h, nextTick, ref } from 'vue'
import Dialog from './Dialog.vue'
import DialogContent from './DialogContent.vue'
import DialogPortal from './DialogPortal.vue'
async function flush() {
await nextTick()
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
function mountDialogWithSelect(modal: boolean) {
const dialogOpen = ref(true)
const selectOpen = ref(false)
const Parent = defineComponent({
setup() {
return () =>
h(
Dialog,
{
modal,
open: dialogOpen.value,
'onUpdate:open': (value: boolean) => (dialogOpen.value = value)
},
() =>
h(DialogPortal, null, () =>
h(DialogContent, null, () =>
h(
SelectRoot,
{
open: selectOpen.value,
'onUpdate:open': (value: boolean) =>
(selectOpen.value = value)
},
() => [
h(SelectTrigger, null, () => 'trigger'),
h(SelectPortal, null, () =>
h(SelectContent, { position: 'popper' }, () =>
h(SelectViewport, null, () => 'items')
)
)
]
)
)
)
)
}
})
return { ...render(Parent), dialogOpen, selectOpen }
}
describe('modal dialog pointer lock', () => {
it('keeps body inert after a nested combobox popover opens and closes', async () => {
const { selectOpen } = mountDialogWithSelect(true)
await flush()
expect(document.body.style.pointerEvents).toBe('none')
selectOpen.value = true
await flush()
expect(document.body.style.pointerEvents).toBe('none')
// Reka restores body pointer events when the popover layer unmounts; the
// lock must re-assert it so the canvas behind the dialog stays inert.
selectOpen.value = false
await flush()
expect(document.body.style.pointerEvents).toBe('none')
})
it('restores body pointer events once the dialog closes', async () => {
const { dialogOpen } = mountDialogWithSelect(true)
await flush()
expect(document.body.style.pointerEvents).toBe('none')
dialogOpen.value = false
await flush()
expect(document.body.style.pointerEvents).toBe('')
})
it('does not lock body for a non-modal dialog', async () => {
mountDialogWithSelect(false)
await flush()
expect(document.body.style.pointerEvents).toBe('')
})
it('holds the body lock until every open modal dialog closes', async () => {
const first = mountDialogWithSelect(true)
const second = mountDialogWithSelect(true)
await flush()
expect(document.body.style.pointerEvents).toBe('none')
// With one modal still open the shared lock must keep the body inert.
first.dialogOpen.value = false
await flush()
expect(document.body.style.pointerEvents).toBe('none')
// Once the last modal closes the lock releases and stops forcing inert.
second.dialogOpen.value = false
await flush()
expect(document.body.style.pointerEvents).not.toBe('none')
})
})

View File

@@ -0,0 +1,54 @@
import { onScopeDispose, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
/**
* Keeps the canvas behind a modal dialog inert by holding `document.body`'s
* pointer-events lock for as long as at least one modal dialog is open.
*
* Reka-UI locks body pointer events per modal layer, but a nested dismissable
* layer that is portalled to the body — e.g. a `Select` popover inside the
* dialog — restores the body's pointer events when it closes, even while the
* outer modal dialog is still open. That momentarily re-enables the canvas, so
* combobox clicks leak through to it and can select a node or dismiss the
* dialog. Reka still performs the initial lock and final restore; the
* `MutationObserver` only re-asserts `none` if the lock is cleared while a
* modal dialog is still open.
*/
let openModalCount = 0
let observer: MutationObserver | null = null
function enforceLock() {
if (openModalCount > 0 && document.body.style.pointerEvents !== 'none') {
document.body.style.pointerEvents = 'none'
}
}
function acquire() {
openModalCount += 1
if (observer) return
observer = new MutationObserver(enforceLock)
observer.observe(document.body, {
attributes: true,
attributeFilter: ['style']
})
}
function release() {
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount > 0) return
observer?.disconnect()
observer = null
document.body.style.pointerEvents = ''
}
export function useModalPointerLock(isOpen: MaybeRefOrGetter<boolean>) {
let holding = false
const sync = (open: boolean) => {
if (open === holding) return
holding = open
if (open) acquire()
else release()
}
watch(() => toValue(isOpen), sync, { immediate: true })
onScopeDispose(() => sync(false))
}

View File

@@ -34,6 +34,7 @@ export enum ServerFeatureFlag {
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
SERVER_SIDE_MODEL_DOWNLOADS = 'server_side_model_downloads'
}
/**
@@ -208,6 +209,12 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get serverSideModelDownloads() {
return api.getServerFeature(
ServerFeatureFlag.SERVER_SIDE_MODEL_DOWNLOADS,
false
)
}
})

View File

@@ -214,7 +214,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) {
@@ -301,7 +303,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

@@ -180,7 +180,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'clay',
'normal',
'wireframe'
])
@@ -244,7 +243,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

@@ -108,7 +108,6 @@ describe('MeshModelAdapter', () => {
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -24,7 +24,7 @@ export class MeshModelAdapter implements ModelAdapter {
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'clay', 'normal', 'wireframe'],
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}

View File

@@ -19,7 +19,6 @@ describe('DEFAULT_MODEL_CAPABILITIES', () => {
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -60,7 +60,7 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'clay', 'normal', 'wireframe'],
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}

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'
@@ -31,7 +29,6 @@ export class SceneModelManager implements ModelManagerInterface {
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
clayMaterial: THREE.MeshStandardMaterial
originalFileName: string | null = null
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
@@ -40,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
@@ -55,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,
@@ -71,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
@@ -101,44 +98,8 @@ export class SceneModelManager implements ModelManagerInterface {
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
this.depthMaterial.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value: this.activeCamera instanceof THREE.OrthographicCamera ? 1.0 : 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
this.depthMaterial.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
this.standardMaterial = this.createSTLMaterial()
this.clayMaterial = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.0,
roughness: 0.9,
flatShading: false,
side: THREE.DoubleSide
})
}
init(): void {}
@@ -149,7 +110,6 @@ export class SceneModelManager implements ModelManagerInterface {
this.standardMaterial.dispose()
this.wireframeMaterial.dispose()
this.depthMaterial.dispose()
this.clayMaterial.dispose()
if (this.appliedTexture) {
this.appliedTexture.dispose()
@@ -236,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) {
@@ -252,25 +212,68 @@ export class SceneModelManager implements ModelManagerInterface {
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.depthMaterial
const depthMat = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
depthMat.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value:
this.activeCamera instanceof THREE.OrthographicCamera
? 1.0
: 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
depthMat.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
child.material = depthMat
break
case 'normal':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.normalMaterial
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
break
case 'wireframe':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.wireframeMaterial
break
case 'clay':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.clayMaterial
child.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
break
case 'original':
case 'pointCloud':

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

@@ -11,7 +11,6 @@ export type MaterialMode =
| 'normal'
| 'wireframe'
| 'depth'
| 'clay'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'

View File

@@ -1076,6 +1076,69 @@
"update": "Update",
"description": "Check out the latest improvements and features in this update."
},
"modelManager": {
"title": "Downloads",
"active": "Active",
"history": "History",
"empty": "No downloads yet",
"clearHistory": "Clear history",
"addModel": "Add model",
"addModelDescription": "Download a model directly to your ComfyUI models folder.",
"url": "URL",
"urlPlaceholder": "https://huggingface.co/.../model.safetensors",
"hostNotAllowedHint": "This host may not be on the download allowlist.",
"folder": "Model folder",
"selectFolder": "Select a folder",
"filename": "Filename",
"filenamePlaceholder": "model.safetensors",
"allowAnyExtension": "Allow any file extension (advanced)",
"download": "Download",
"downloadQueued": "Download queued",
"alreadyInstalled": "Model already installed",
"alreadyDownloading": "Model is already downloading",
"resume": "Resume",
"raisePriority": "Raise priority",
"removeFromList": "Remove from list",
"setUpDownloadAccess": "Set up download access",
"authErrorHint": "{host} needs authentication. Set up download access, then resume.",
"authErrorHintNoHost": "This download needs authentication. Set up download access, then resume.",
"gatedModelHint": "This model is gated. Accept its license on the model's page, set up download access, then resume.",
"openModelPage": "Accept license",
"actionFailed": "Action failed",
"cancelConfirmTitle": "Cancel download?",
"cancelConfirmMessage": "This will stop the download and delete the partial file for \"{name}\".",
"cancelConfirm": "Cancel download",
"status": {
"queued": "Queued",
"active": "Downloading",
"paused": "Paused",
"verifying": "Verifying",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled"
},
"downloadAuth": {
"title": "Download access",
"description": "Connect HuggingFace and Civitai to download their gated or private models.",
"connected": "Connected",
"notConnected": "Not connected",
"envKeyOnServer": "API key set on the server",
"logIn": "Log in",
"signOut": "Sign out",
"howToAddKey": "How to add an API key",
"loginFailed": "Login didn't complete. Try again, or add an API key on the server.",
"instructions": {
"remoteNotice": "ComfyUI is running on a remote server, so the API key must be set on that server.",
"createToken": "Create a token",
"setEnvVar": "On the server, set {envVar} and restart ComfyUI:",
"altEnvVar": "You can also use {envVar}.",
"restartNote": "Environment variables are read at startup, so restart the ComfyUI server process after setting one.",
"gatedNote": "If the model is gated, also request access on the model's page.",
"copy": "Copy",
"recheck": "I've added the key — re-check"
}
}
},
"menu": {
"hideMenu": "Hide Menu",
"showMenu": "Show Menu",
@@ -2068,7 +2131,6 @@
"centerCameraOnModel": "Center Camera on Model",
"scene": "Scene",
"model": "Model",
"model3d": "3D Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
@@ -2079,35 +2141,13 @@
"reloadingModel": "Reloading model...",
"uploadTexture": "Upload Texture",
"applyingTexture": "Applying Texture...",
"menuBar": {
"showGrid": "Show grid",
"bgColor": "BG Color",
"bgImage": "BG Image",
"panorama": "Panorama",
"removeBackground": "Remove BG",
"upDirection": "Up Direction",
"material": "Material",
"skeleton": "Skeleton",
"fov": "FOV",
"intensity": "Intensity",
"record": "Record",
"recording": "Recording",
"stopRecording": "Stop recording",
"downloadRecording": "Download Recording",
"startNewRecording": "Start New Recording",
"deleteRecording": "Delete Recording",
"videoRecordingTooltip": "Video recording of the scene [mp4]",
"switchProjection": "Switch projection",
"originalMaterialOnly": "Original material only"
},
"materialModes": {
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"pointCloud": "Point Cloud",
"depth": "Depth",
"lineart": "Lineart",
"clay": "Clay"
"lineart": "Lineart"
},
"upDirections": {
"original": "Original"
@@ -2139,10 +2179,10 @@
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI",
"uploadFile": "Upload",
"changeFile": "Change",
"removeFile": "Remove",
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
},
@@ -2152,7 +2192,7 @@
"translate": "Translate",
"rotate": "Rotate",
"scale": "Scale",
"reset": "Reset"
"reset": "Reset Transform"
}
},
"imageCrop": {

View File

@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DownloadApiError } from '@/platform/modelManager/types'
import {
downloadModel,
fetchModelMetadata,
@@ -7,13 +9,23 @@ import {
toBrowsableUrl
} from './missingModelDownload'
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
vi.hoisted(() => ({
fetchMock: vi.fn(),
mockIsDesktop: { value: false },
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
mockStartDownload: vi.fn()
}))
const {
fetchMock,
mockIsDesktop,
mockSidebarTabStore,
mockStartDownload,
mockEnqueue,
mockToastAdd,
mockFlags
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
mockIsDesktop: { value: false },
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
mockStartDownload: vi.fn(),
mockEnqueue: vi.fn(),
mockToastAdd: vi.fn(),
mockFlags: { serverSideModelDownloads: false }
}))
vi.stubGlobal('fetch', fetchMock)
@@ -33,12 +45,38 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/modelManager/stores/modelDownloadStore', () => ({
useModelDownloadStore: () => ({ enqueue: mockEnqueue })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
const mockRefreshModelFolder = vi.fn()
const mockRefreshMissingModels = vi.fn()
vi.mock('@/stores/modelStore', () => ({
useModelStore: () => ({ refreshModelFolder: mockRefreshModelFolder })
}))
vi.mock('@/platform/missingModel/missingModelStore', () => ({
useMissingModelStore: () => ({
refreshMissingModels: mockRefreshMissingModels
})
}))
let testId = 0
beforeEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
delete window.__comfyDesktop2
mockFlags.serverSideModelDownloads = false
})
describe('fetchModelMetadata', () => {
@@ -117,6 +155,26 @@ describe('fetchModelMetadata', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it('returns null metadata when the Civitai request throws', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'))
const metadata = await fetchModelMetadata(
`https://civitai.com/api/download/models/${testId}`
)
expect(metadata).toEqual({ fileSize: null, gatedRepoUrl: null })
})
it('returns null metadata when the HEAD request throws', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'))
const metadata = await fetchModelMetadata(
`https://huggingface.co/org/model/resolve/main/throws-${testId}.safetensors`
)
expect(metadata).toEqual({ fileSize: null, gatedRepoUrl: null })
})
it('returns cached metadata on second call', async () => {
const url = `https://huggingface.co/org/model/resolve/main/cached-${testId}.safetensors`
@@ -240,6 +298,16 @@ describe('isModelDownloadable', () => {
})
).toBe(false)
})
it('allows explicitly whitelisted URLs from an otherwise disallowed host', () => {
expect(
isModelDownloadable({
name: 'RealESRGAN_x4plus.pth',
url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth',
directory: 'upscale_models'
})
).toBe(true)
})
})
describe('downloadModel', () => {
@@ -379,6 +447,152 @@ describe('downloadModel', () => {
expect(anchorClick).toHaveBeenCalledTimes(1)
})
it('uses the browser fallback on web when server-side downloads are disabled', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(anchorClick).toHaveBeenCalledTimes(1)
expect(mockEnqueue).not.toHaveBeenCalled()
})
it('enqueues a server-side download and reveals the manager when enabled', async () => {
mockFlags.serverSideModelDownloads = true
mockEnqueue.mockResolvedValue({ download_id: 'd1', accepted: true })
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-manager')
})
expect(mockEnqueue).toHaveBeenCalledWith({
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
model_id: 'checkpoints/model.safetensors'
})
expect(anchorClick).not.toHaveBeenCalled()
})
it('shows a toast when a server-side enqueue fails', async () => {
mockFlags.serverSideModelDownloads = true
mockEnqueue.mockRejectedValue(new Error('boom'))
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error', detail: 'boom' })
)
})
expect(mockSidebarTabStore.activeSidebarTabId).toBeNull()
})
it('reveals the download manager and shows an info toast for an in-progress download', async () => {
mockFlags.serverSideModelDownloads = true
mockEnqueue.mockRejectedValue(
new DownloadApiError('exists', 'ALREADY_DOWNLOADING', 409)
)
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-manager')
})
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'info',
detail: 'model.safetensors'
})
)
})
it('refreshes the model folder and re-scans missing models when already available', async () => {
mockFlags.serverSideModelDownloads = true
mockEnqueue.mockRejectedValue(
new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409)
)
mockRefreshModelFolder.mockResolvedValue(undefined)
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'info',
detail: 'model.safetensors'
})
)
})
await vi.waitFor(() => {
expect(mockRefreshModelFolder).toHaveBeenCalledWith('checkpoints')
})
expect(mockRefreshMissingModels).toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBeNull()
})
it('still re-scans missing models when the post-available folder refresh fails', async () => {
mockFlags.serverSideModelDownloads = true
mockEnqueue.mockRejectedValue(
new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409)
)
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockRefreshModelFolder.mockRejectedValue(new Error('boom'))
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(mockRefreshMissingModels).toHaveBeenCalled()
})
expect(consoleWarn).toHaveBeenCalled()
consoleWarn.mockRestore()
})
it('opens the model library sidebar before starting a desktop download', () => {
mockIsDesktop.value = true

View File

@@ -1,5 +1,11 @@
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { t } from '@/i18n'
import { isDesktop } from '@/platform/distribution/types'
import { useModelDownloadStore } from '@/platform/modelManager/stores/modelDownloadStore'
import { DownloadApiError } from '@/platform/modelManager/types'
import { buildModelId } from '@/platform/modelManager/utils/modelId'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ComfyDesktop2Bridge } from '@/types'
@@ -29,6 +35,7 @@ const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
])
const MODEL_LIBRARY_TAB_ID = 'model-library'
const MODEL_MANAGER_TAB_ID = 'model-manager'
export interface ModelWithUrl {
name: string
@@ -47,6 +54,96 @@ async function startDesktop2ModelDownload(
}
}
function revealDownloadManager(): void {
useSidebarTabStore().activeSidebarTabId = MODEL_MANAGER_TAB_ID
}
/**
* Already on disk: surface a confirmation, refresh the model folder, and
* re-scan missing models so any node error for this model clears. Loaded
* lazily to keep this module's import graph (and its unit tests) light.
*/
async function refreshAfterModelAvailable(model: ModelWithUrl): Promise<void> {
try {
const [{ useModelStore }, { useMissingModelStore }] = await Promise.all([
import('@/stores/modelStore'),
import('@/platform/missingModel/missingModelStore')
])
if (model.directory) {
try {
await useModelStore().refreshModelFolder(model.directory)
} catch (error) {
console.warn(
'[MissingModel] Failed to refresh model folder after model available',
error
)
}
}
void useMissingModelStore().refreshMissingModels()
} catch (error) {
console.warn(
'[MissingModel] Failed to refresh after model available',
error
)
}
}
/**
* Enqueues a server-side download and reveals the Model Manager panel so the
* user can watch live progress, status, and completion. The two benign `409`
* cases get an info toast: `ALREADY_DOWNLOADING` links to the existing job,
* `ALREADY_AVAILABLE` confirms it's installed and clears the node error. Any
* other failure is reported via an error toast.
*/
async function startServerSideModelDownload(
model: ModelWithUrl
): Promise<void> {
const toast = useToastStore()
try {
await useModelDownloadStore().enqueue({
url: model.url,
model_id: buildModelId(model.directory, model.name)
})
revealDownloadManager()
} catch (error: unknown) {
if (error instanceof DownloadApiError && error.is('ALREADY_DOWNLOADING')) {
revealDownloadManager()
toast.add({
severity: 'info',
summary: t('modelManager.alreadyDownloading'),
detail: model.name,
life: 4000
})
return
}
if (error instanceof DownloadApiError && error.is('ALREADY_AVAILABLE')) {
toast.add({
severity: 'info',
summary: t('modelManager.alreadyInstalled'),
detail: model.name,
life: 4000
})
void refreshAfterModelAvailable(model)
return
}
toast.add({
severity: 'error',
summary: t('modelManager.actionFailed'),
detail: error instanceof Error ? error.message : String(error),
life: 5000
})
}
}
function startBrowserModelDownload(model: ModelWithUrl): void {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
@@ -82,12 +179,11 @@ export function downloadModel(
}
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
if (useFeatureFlags().flags.serverSideModelDownloads) {
void startServerSideModelDownload(model)
} else {
startBrowserModelDownload(model)
}
return
}

View File

@@ -0,0 +1,279 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { DownloadApiError } from '../types'
import {
cancelDownload,
checkAvailability,
clearDownloads,
deleteDownload,
enqueueDownload,
getDownloadAuth,
listDownloads,
logoutProvider,
pauseDownload,
resumeDownload,
setDownloadPriority,
startProviderLogin
} from './modelDownloadApi'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
const fetchApi = vi.mocked(api.fetchApi)
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: `status ${status}`,
json: () => Promise.resolve(body)
} as unknown as Response
}
function nonJsonErrorResponse(status: number, statusText: string): Response {
return {
ok: false,
status,
statusText,
json: () => Promise.reject(new Error('not json'))
} as unknown as Response
}
describe('modelDownloadApi', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('enqueueDownload', () => {
it('returns the enqueue response on 202', async () => {
fetchApi.mockResolvedValue(
jsonResponse(202, { download_id: 'd1', accepted: true })
)
const result = await enqueueDownload({
url: 'https://huggingface.co/x.safetensors',
model_id: 'loras/x.safetensors'
})
expect(result).toEqual({ download_id: 'd1', accepted: true })
expect(fetchApi).toHaveBeenCalledWith(
'/download/enqueue',
expect.objectContaining({ method: 'POST' })
)
})
it('throws a DownloadApiError carrying the error code on 409', async () => {
fetchApi.mockResolvedValue(
jsonResponse(409, {
error: { code: 'ALREADY_DOWNLOADING', message: 'exists' }
})
)
await expect(
enqueueDownload({ url: 'u', model_id: 'loras/x.safetensors' })
).rejects.toMatchObject({
code: 'ALREADY_DOWNLOADING',
status: 409,
message: 'exists'
})
})
it('falls back to statusText and an UNKNOWN code for a non-JSON error body', async () => {
fetchApi.mockResolvedValue(nonJsonErrorResponse(502, 'Bad Gateway'))
await expect(
enqueueDownload({ url: 'u', model_id: 'loras/x.safetensors' })
).rejects.toMatchObject({
message: 'Bad Gateway',
code: 'UNKNOWN',
status: 502
})
})
it('exposes the code through DownloadApiError.is()', async () => {
fetchApi.mockResolvedValue(
jsonResponse(400, {
error: { code: 'URL_NOT_ALLOWED', message: 'nope' }
})
)
const error = await enqueueDownload({
url: 'u',
model_id: 'loras/x.safetensors'
}).catch((e) => e)
expect(error).toBeInstanceOf(DownloadApiError)
expect(error.is('URL_NOT_ALLOWED')).toBe(true)
})
})
describe('listDownloads', () => {
it('unwraps the downloads array', async () => {
fetchApi.mockResolvedValue(
jsonResponse(200, { downloads: [{ download_id: 'd1' }] })
)
const result = await listDownloads()
expect(result).toEqual([{ download_id: 'd1' }])
})
})
describe('actions', () => {
it('posts to the pause route', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { ok: true }))
await pauseDownload('d1')
expect(fetchApi).toHaveBeenCalledWith(
'/download/d1/pause',
expect.objectContaining({ method: 'POST' })
)
})
it('posts to the resume route', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { ok: true }))
await resumeDownload('d1')
expect(fetchApi).toHaveBeenCalledWith(
'/download/d1/resume',
expect.objectContaining({ method: 'POST' })
)
})
it('posts to the cancel route', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { ok: true }))
await cancelDownload('d1')
expect(fetchApi).toHaveBeenCalledWith(
'/download/d1/cancel',
expect.objectContaining({ method: 'POST' })
)
})
it('sends the priority in the body', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { ok: true }))
await setDownloadPriority('d1', 5)
expect(fetchApi).toHaveBeenCalledWith(
'/download/d1/priority',
expect.objectContaining({ body: JSON.stringify({ priority: 5 }) })
)
})
it('sends a DELETE to the download route', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { deleted: true }))
await deleteDownload('d1')
expect(fetchApi).toHaveBeenCalledWith(
'/download/d1',
expect.objectContaining({ method: 'DELETE' })
)
})
it('posts to the clear route and returns the deleted count', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { deleted: 3 }))
const result = await clearDownloads()
expect(result).toBe(3)
expect(fetchApi).toHaveBeenCalledWith(
'/download/clear',
expect.objectContaining({ method: 'POST' })
)
})
})
describe('checkAvailability', () => {
it('wraps the models map in the request body', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { models: {} }))
await checkAvailability({ 'loras/x.safetensors': 'https://h.co/x' })
expect(fetchApi).toHaveBeenCalledWith(
'/download/availability',
expect.objectContaining({
body: JSON.stringify({
models: { 'loras/x.safetensors': 'https://h.co/x' }
})
})
)
})
})
describe('auth', () => {
it('unwraps the provider status list from GET /download/auth', async () => {
fetchApi.mockResolvedValue(
jsonResponse(200, {
providers: [
{
provider: 'huggingface',
env_key_present: true,
logged_in: false,
login_in_progress: false
}
]
})
)
const result = await getDownloadAuth()
expect(fetchApi).toHaveBeenCalledWith('/download/auth')
expect(result).toEqual([
{
provider: 'huggingface',
env_key_present: true,
logged_in: false,
login_in_progress: false
}
])
})
it('posts to the provider login route and returns the authorize url', async () => {
fetchApi.mockResolvedValue(
jsonResponse(200, { authorize_url: 'https://auth.example/go' })
)
const result = await startProviderLogin('huggingface')
expect(fetchApi).toHaveBeenCalledWith(
'/download/auth/huggingface/login',
expect.objectContaining({ method: 'POST' })
)
expect(result).toEqual({ authorize_url: 'https://auth.example/go' })
})
it('surfaces OAUTH_NOT_CONFIGURED as a DownloadApiError', async () => {
fetchApi.mockResolvedValue(
jsonResponse(400, {
error: { code: 'OAUTH_NOT_CONFIGURED', message: 'no client id' }
})
)
const error = await startProviderLogin('civitai').catch((e) => e)
expect(error).toBeInstanceOf(DownloadApiError)
expect(error.is('OAUTH_NOT_CONFIGURED')).toBe(true)
})
it('posts to the provider logout route', async () => {
fetchApi.mockResolvedValue(jsonResponse(200, { logged_out: true }))
await logoutProvider('civitai')
expect(fetchApi).toHaveBeenCalledWith(
'/download/auth/civitai/logout',
expect.objectContaining({ method: 'POST' })
)
})
})
})

View File

@@ -0,0 +1,127 @@
import { api } from '@/scripts/api'
import type {
AvailabilityResponse,
DownloadProvider,
DownloadStatus,
EnqueueRequest,
EnqueueResponse,
LoginStartResponse,
ProviderAuthStatus
} from '../types'
import { DownloadApiError } from '../types'
const BASE = '/download'
interface ErrorEnvelope {
error?: {
code?: string
message?: string
details?: Record<string, unknown>
}
}
async function throwFromResponse(response: Response): Promise<never> {
let body: ErrorEnvelope = {}
try {
body = await response.json()
} catch {
// Non-JSON error body
}
const error = body.error
throw new DownloadApiError(
error?.message ?? response.statusText,
error?.code ?? 'UNKNOWN',
response.status,
error?.details
)
}
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) return throwFromResponse(response)
return response.json() as Promise<T>
}
function postJson(route: string, body: unknown): Promise<Response> {
return api.fetchApi(route, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
}
export async function enqueueDownload(
body: EnqueueRequest
): Promise<EnqueueResponse> {
const response = await postJson(`${BASE}/enqueue`, body)
if (response.status === 202) {
return response.json() as Promise<EnqueueResponse>
}
return throwFromResponse(response)
}
export async function listDownloads(): Promise<DownloadStatus[]> {
const response = await api.fetchApi(BASE)
const data = await parseJson<{ downloads: DownloadStatus[] }>(response)
return data.downloads
}
async function postAction(id: string, action: string): Promise<void> {
const response = await postJson(`${BASE}/${id}/${action}`, undefined)
await parseJson<{ ok: boolean }>(response)
}
export const pauseDownload = (id: string) => postAction(id, 'pause')
export const resumeDownload = (id: string) => postAction(id, 'resume')
export const cancelDownload = (id: string) => postAction(id, 'cancel')
export async function deleteDownload(id: string): Promise<void> {
const response = await api.fetchApi(`${BASE}/${id}`, { method: 'DELETE' })
await parseJson<{ deleted: boolean }>(response)
}
export async function clearDownloads(): Promise<number> {
const response = await postJson(`${BASE}/clear`, undefined)
const data = await parseJson<{ deleted: number }>(response)
return data.deleted
}
export async function setDownloadPriority(
id: string,
priority: number
): Promise<void> {
const response = await postJson(`${BASE}/${id}/priority`, { priority })
await parseJson<{ ok: boolean }>(response)
}
export async function checkAvailability(
models: Record<string, string>
): Promise<AvailabilityResponse> {
const response = await postJson(`${BASE}/availability`, { models })
return parseJson<AvailabilityResponse>(response)
}
export async function getDownloadAuth(): Promise<ProviderAuthStatus[]> {
const response = await api.fetchApi(`${BASE}/auth`)
const data = await parseJson<{ providers: ProviderAuthStatus[] }>(response)
return data.providers
}
/**
* Starts an OAuth login and returns the URL to open in the browser. Throws a
* {@link DownloadApiError} with `OAUTH_NOT_CONFIGURED` when the server has no
* OAuth client id, or `LOGIN_IN_PROGRESS` when one is already running.
*/
export async function startProviderLogin(
provider: DownloadProvider
): Promise<LoginStartResponse> {
const response = await postJson(`${BASE}/auth/${provider}/login`, undefined)
return parseJson<LoginStartResponse>(response)
}
export async function logoutProvider(
provider: DownloadProvider
): Promise<void> {
const response = await postJson(`${BASE}/auth/${provider}/logout`, undefined)
await parseJson<{ logged_out: boolean }>(response)
}

View File

@@ -0,0 +1,253 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { DownloadApiError } from '../types'
import AddModelByUrlDialog from './AddModelByUrlDialog.vue'
const mockEnqueue = vi.fn()
const mockToastAdd = vi.fn()
const mockFetchModelTypes = vi.fn()
const mockModelTypes = ref([
{ name: 'Checkpoints', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
])
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => ({ enqueue: mockEnqueue })
}))
vi.mock('@/platform/assets/composables/useModelTypes', () => ({
useModelTypes: () => ({
modelTypes: mockModelTypes,
isLoading: ref(false),
fetchModelTypes: mockFetchModelTypes
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
const stubs = {
Dialog: { template: '<div><slot /></div>' },
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: defineComponent({
emits: ['open-auto-focus'],
mounted() {
this.$emit('open-auto-focus')
},
template: '<div><slot /></div>'
}),
DialogHeader: { template: '<div><slot /></div>' },
DialogTitle: { template: '<div><slot /></div>' },
DialogDescription: { template: '<div><slot /></div>' },
DialogFooter: { template: '<div><slot /></div>' },
SingleSelect: {
props: ['modelValue', 'options', 'label', 'loading'],
emits: ['update:modelValue'],
template: `
<select
data-testid="folder-select"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', $event.target.value)"
>
<option value="" disabled>{{ label }}</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
`
}
}
function mountDialog(open = true) {
return render(AddModelByUrlDialog, {
props: { open },
global: { plugins: [i18n], stubs }
})
}
async function fillValidForm() {
await userEvent.type(
screen.getByLabelText('URL'),
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
await userEvent.selectOptions(
screen.getByTestId('folder-select'),
'checkpoints'
)
}
describe('AddModelByUrlDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('auto-fills the filename from the url until edited manually', async () => {
mountDialog()
const urlInput = screen.getByLabelText('URL')
const filenameInput = screen.getByLabelText('Filename')
await userEvent.type(
urlInput,
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
expect(filenameInput).toHaveValue('model.safetensors')
await userEvent.clear(filenameInput)
await userEvent.type(filenameInput, 'custom.safetensors')
await userEvent.type(urlInput, '?download=true')
expect(filenameInput).toHaveValue('custom.safetensors')
})
it('shows a hint for a url on a non-allowlisted host', async () => {
mountDialog()
await userEvent.type(
screen.getByLabelText('URL'),
'https://example.com/model.safetensors'
)
expect(
screen.getByText('This host may not be on the download allowlist.')
).toBeInTheDocument()
})
it('disables submit until url, folder, and a valid filename are set', async () => {
mountDialog()
const submit = screen.getByRole('button', { name: 'Download' })
expect(submit).toBeDisabled()
await userEvent.type(
screen.getByLabelText('URL'),
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
expect(submit).toBeDisabled()
await userEvent.selectOptions(
screen.getByTestId('folder-select'),
'checkpoints'
)
expect(submit).toBeEnabled()
})
it('requires a known model extension', async () => {
mountDialog()
await userEvent.type(
screen.getByLabelText('URL'),
'https://huggingface.co/org/model/resolve/main/readme.txt'
)
await userEvent.selectOptions(
screen.getByTestId('folder-select'),
'checkpoints'
)
const submit = screen.getByRole('button', { name: 'Download' })
expect(submit).toBeDisabled()
})
it('closes without submitting when cancel is clicked', async () => {
const { emitted } = mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockEnqueue).not.toHaveBeenCalled()
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('enqueues the download and closes on success', async () => {
mockEnqueue.mockResolvedValue({ download_id: 'd1', accepted: true })
const { emitted } = mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
expect(mockEnqueue).toHaveBeenCalledWith({
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
model_id: 'checkpoints/model.safetensors',
allow_any_extension: false
})
await vi.waitFor(() => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('shows an info toast and closes when the model is already downloading', async () => {
mockEnqueue.mockRejectedValue(
new DownloadApiError('exists', 'ALREADY_DOWNLOADING', 409)
)
const { emitted } = mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
await vi.waitFor(() => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'info' })
)
})
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('shows an info toast and closes when the model is already installed', async () => {
mockEnqueue.mockRejectedValue(
new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409)
)
const { emitted } = mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
await vi.waitFor(() => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'info' })
)
})
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('shows an inline error for other API failures and stays open', async () => {
mockEnqueue.mockRejectedValue(
new DownloadApiError('url not allowed', 'URL_NOT_ALLOWED', 400)
)
const { emitted } = mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
await vi.waitFor(() => {
expect(screen.getByText('url not allowed')).toBeInTheDocument()
})
expect(emitted('update:open')).toBeUndefined()
})
it('shows a generic error message for a non-api error', async () => {
mockEnqueue.mockRejectedValue(new Error('network down'))
mountDialog()
await fillValidForm()
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
await vi.waitFor(() => {
expect(screen.getByText('network down')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,248 @@
<template>
<Dialog v-model:open="isOpen">
<DialogPortal>
<DialogOverlay class="bg-black/70" />
<DialogContent
size="md"
class="flex flex-col gap-4 p-6"
@open-auto-focus="onOpen"
>
<DialogHeader>
<DialogTitle>{{ $t('modelManager.addModel') }}</DialogTitle>
<DialogDescription>
{{ $t('modelManager.addModelDescription') }}
</DialogDescription>
</DialogHeader>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="download-url">
{{ $t('modelManager.url') }}
</label>
<Input
id="download-url"
v-model="url"
:placeholder="$t('modelManager.urlPlaceholder')"
@update:model-value="onUrlChanged"
/>
<p v-if="hostHint" class="text-xs text-amber-400">{{ hostHint }}</p>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground">
{{ $t('modelManager.folder') }}
</label>
<SingleSelect
v-model="directory"
:options="folderOptions"
:label="$t('modelManager.selectFolder')"
:loading="isLoadingFolders"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="download-filename">
{{ $t('modelManager.filename') }}
</label>
<Input
id="download-filename"
v-model="filename"
:placeholder="$t('modelManager.filenamePlaceholder')"
@update:model-value="onFilenameEdited"
/>
</div>
<!-- TODO: re-enable once we think we'd want to allow any extension
<label class="flex w-fit items-center gap-2 text-xs text-muted-foreground">
<input v-model="allowAnyExtension" type="checkbox" class="size-4" />
{{ $t('modelManager.allowAnyExtension') }}
</label>
-->
<p
v-if="modelId"
class="truncate rounded-md bg-secondary-background px-2 py-1 text-xs text-muted-foreground"
>
{{ modelId }}
</p>
<p v-if="errorMessage" class="text-xs text-red-400">
{{ errorMessage }}
</p>
<DialogFooter>
<Button variant="secondary" @click="isOpen = false">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
:disabled="!canSubmit"
:loading="isSubmitting"
@click="submit"
>
{{ $t('modelManager.download') }}
</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Input from '@/components/ui/input/Input.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { providerForUrl } from '../downloadAuthProviders'
import { useModelDownloadStore } from '../stores/modelDownloadStore'
import { DownloadApiError } from '../types'
import type { DownloadProvider } from '../types'
import {
buildModelId,
filenameFromUrl,
hasModelExtension,
isLikelyAllowedHost,
isValidPathSegment
} from '../utils/modelId'
const isOpen = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{
authRequired: [provider: DownloadProvider | undefined]
}>()
const { t } = useI18n()
const store = useModelDownloadStore()
const {
modelTypes,
isLoading: isLoadingFolders,
fetchModelTypes
} = useModelTypes()
const url = ref('')
const directory = ref<string | undefined>(undefined)
const filename = ref('')
const isFilenameUserEdited = ref(false)
const allowAnyExtension = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const folderOptions = computed(() => modelTypes.value)
const modelId = computed(() =>
directory.value && filename.value
? buildModelId(directory.value, filename.value)
: ''
)
const hostHint = computed(() =>
url.value && !isLikelyAllowedHost(url.value)
? t('modelManager.hostNotAllowedHint')
: ''
)
const canSubmit = computed(
() =>
!!url.value &&
!!directory.value &&
isValidPathSegment(filename.value) &&
(allowAnyExtension.value || hasModelExtension(filename.value))
)
function onOpen() {
void fetchModelTypes()
errorMessage.value = ''
}
function onUrlChanged() {
if (!isFilenameUserEdited.value) {
filename.value = filenameFromUrl(url.value)
}
}
function onFilenameEdited() {
isFilenameUserEdited.value = true
}
function reset() {
url.value = ''
directory.value = undefined
filename.value = ''
isFilenameUserEdited.value = false
allowAnyExtension.value = false
errorMessage.value = ''
}
async function submit() {
if (!canSubmit.value) return
isSubmitting.value = true
errorMessage.value = ''
try {
await store.enqueue({
url: url.value,
model_id: modelId.value,
allow_any_extension: allowAnyExtension.value
})
useToastStore().add({
severity: 'success',
summary: t('modelManager.downloadQueued'),
detail: modelId.value,
life: 4000
})
reset()
isOpen.value = false
} catch (error) {
handleEnqueueError(error)
} finally {
isSubmitting.value = false
}
}
function handleEnqueueError(error: unknown) {
if (error instanceof DownloadApiError) {
if (error.is('ALREADY_AVAILABLE')) {
useToastStore().add({
severity: 'info',
summary: t('modelManager.alreadyInstalled'),
detail: modelId.value,
life: 4000
})
reset()
isOpen.value = false
return
}
if (error.is('ALREADY_DOWNLOADING')) {
useToastStore().add({
severity: 'info',
summary: t('modelManager.alreadyDownloading'),
detail: modelId.value,
life: 4000
})
reset()
isOpen.value = false
return
}
if (error.is('GATED_REPO') || error.is('CREDENTIALS_REQUIRED')) {
errorMessage.value = error.message
emit('authRequired', providerForUrl(url.value))
return
}
errorMessage.value = error.message
return
}
errorMessage.value =
error instanceof Error ? error.message : t('modelManager.actionFailed')
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<Dialog v-model:open="isOpen">
<DialogPortal>
<DialogOverlay class="bg-black/70" />
<DialogContent
size="md"
class="flex flex-col gap-4 p-6"
@open-auto-focus="onOpen"
>
<DialogHeader>
<DialogTitle>{{ $t('modelManager.downloadAuth.title') }}</DialogTitle>
<DialogDescription>
{{ $t('modelManager.downloadAuth.description') }}
</DialogDescription>
</DialogHeader>
<div class="flex flex-col gap-2">
<DownloadAuthProviderRow
v-for="config in providers"
:key="config.id"
:provider="config.id"
:initially-expanded="config.id === focusProvider"
/>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import DownloadAuthProviderRow from './DownloadAuthProviderRow.vue'
import { DOWNLOAD_PROVIDER_CONFIGS } from '../downloadAuthProviders'
import { useDownloadAuthStore } from '../stores/downloadAuthStore'
import type { DownloadProvider } from '../types'
const { focusProvider } = defineProps<{ focusProvider?: DownloadProvider }>()
const isOpen = defineModel<boolean>('open', { required: true })
const store = useDownloadAuthStore()
const providers = DOWNLOAD_PROVIDER_CONFIGS
async function onOpen() {
await store.fetchStatus().catch(() => {})
}
</script>

View File

@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { ProviderAuthStatus } from '../types'
import DownloadAuthProviderRow from './DownloadAuthProviderRow.vue'
const h = vi.hoisted(() => ({
isLocal: true,
statuses: new Map<string, ProviderAuthStatus>(),
login: vi.fn(),
logout: vi.fn(),
fetchStatus: vi.fn(),
copyToClipboard: vi.fn()
}))
vi.mock('../utils/deployment', () => ({
isLocalDeployment: () => h.isLocal
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copyToClipboard: h.copyToClipboard })
}))
vi.mock('../stores/downloadAuthStore', () => ({
useDownloadAuthStore: () => ({
isLoading: false,
statusFor: (provider: string) => h.statuses.get(provider),
isAuthenticated: (provider: string) => {
const status = h.statuses.get(provider)
return !!status && (status.env_key_present || status.logged_in)
},
login: h.login,
logout: h.logout,
fetchStatus: h.fetchStatus
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function setStatus(overrides: Partial<ProviderAuthStatus> = {}) {
h.statuses.set('huggingface', {
provider: 'huggingface',
env_key_present: false,
logged_in: false,
login_in_progress: false,
...overrides
})
}
function mountRow(initiallyExpanded = false) {
return render(DownloadAuthProviderRow, {
props: { provider: 'huggingface', initiallyExpanded },
global: { plugins: [i18n] }
})
}
describe('DownloadAuthProviderRow', () => {
beforeEach(() => {
vi.clearAllMocks()
h.isLocal = true
h.statuses = new Map()
})
it('shows the env-key state without a log-in or sign-out control', () => {
setStatus({ env_key_present: true })
mountRow()
expect(screen.getByText('API key set on the server')).toBeInTheDocument()
expect(screen.queryByText('Log in')).not.toBeInTheDocument()
expect(screen.queryByText('Sign out')).not.toBeInTheDocument()
})
it('shows Connected with a sign-out control when logged in via OAuth', async () => {
setStatus({ logged_in: true })
mountRow()
expect(screen.getByText('Connected')).toBeInTheDocument()
await userEvent.click(screen.getByText('Sign out'))
expect(h.logout).toHaveBeenCalledWith('huggingface')
})
it('offers OAuth login on a local deployment and reveals instructions on fallback', async () => {
setStatus()
h.login.mockResolvedValue('needs_env_key')
mountRow()
await userEvent.click(screen.getByText('Log in'))
expect(h.login).toHaveBeenCalledWith('huggingface')
expect(
screen.getByText('On the server, set HF_TOKEN and restart ComfyUI:')
).toBeInTheDocument()
})
it('shows env-var instructions instead of login on a remote deployment', async () => {
setStatus()
h.isLocal = false
mountRow()
expect(screen.queryByText('Log in')).not.toBeInTheDocument()
await userEvent.click(screen.getByText('How to add an API key'))
expect(screen.getByText(/running on a remote server/)).toBeInTheDocument()
expect(screen.getByText('export HF_TOKEN="hf_xxx"')).toBeInTheDocument()
await userEvent.click(screen.getByTitle('Copy'))
expect(h.copyToClipboard).toHaveBeenCalledWith('export HF_TOKEN="hf_xxx"')
})
it('auto-expands instructions when requested for an unauthenticated provider', () => {
setStatus()
mountRow(true)
expect(
screen.getByText('On the server, set HF_TOKEN and restart ComfyUI:')
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,185 @@
<template>
<div
class="flex flex-col gap-3 rounded-lg border border-border-default bg-secondary-background p-3"
>
<div class="flex items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-medium text-base-foreground">
{{ config.label }}
</span>
<span
v-if="isAuthenticated"
class="flex items-center gap-1 text-xs text-green-400"
>
<i class="icon-[lucide--check] size-3.5" />
{{
envKeyPresent
? $t('modelManager.downloadAuth.envKeyOnServer')
: $t('modelManager.downloadAuth.connected')
}}
</span>
<span v-else class="text-xs text-muted-foreground">
{{ $t('modelManager.downloadAuth.notConnected') }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<template v-if="isAuthenticated">
<Button
v-if="loggedInViaOAuth"
variant="secondary"
size="sm"
@click="onLogout"
>
{{ $t('modelManager.downloadAuth.signOut') }}
</Button>
</template>
<template v-else-if="isLocal">
<Button
variant="primary"
size="sm"
:loading="isLoggingIn"
@click="onLogin"
>
{{ $t('modelManager.downloadAuth.logIn') }}
</Button>
</template>
<Button
v-if="!isAuthenticated"
variant="secondary"
size="sm"
@click="showInstructions = !showInstructions"
>
{{ $t('modelManager.downloadAuth.howToAddKey') }}
</Button>
</div>
</div>
<p v-if="loginFailed" class="text-xs text-amber-400">
{{ $t('modelManager.downloadAuth.loginFailed') }}
</p>
<div
v-if="showInstructions"
class="flex flex-col gap-2 border-t border-border-default pt-3 text-xs text-muted-foreground"
>
<p v-if="!isLocal">
{{ $t('modelManager.downloadAuth.instructions.remoteNotice') }}
</p>
<p>
<a
:href="config.tokenUrl"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
{{ $t('modelManager.downloadAuth.instructions.createToken') }}
</a>
</p>
<p>
{{
$t('modelManager.downloadAuth.instructions.setEnvVar', {
envVar: primaryEnvVar
})
}}
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 rounded-md bg-base-background px-2 py-1 font-mono text-base-foreground"
>
{{ snippet }}
</code>
<Button
variant="textonly"
size="icon"
:title="$t('modelManager.downloadAuth.instructions.copy')"
@click="copyToClipboard(snippet)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<p v-if="altEnvVar">
{{
$t('modelManager.downloadAuth.instructions.altEnvVar', {
envVar: altEnvVar
})
}}
</p>
<p>{{ $t('modelManager.downloadAuth.instructions.restartNote') }}</p>
<p v-if="config.canBeGated">
{{ $t('modelManager.downloadAuth.instructions.gatedNote') }}
</p>
<div class="flex justify-end">
<Button variant="link" size="sm" :loading="isLoading" @click="recheck">
{{ $t('modelManager.downloadAuth.instructions.recheck') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { envExportSnippet, getProviderConfig } from '../downloadAuthProviders'
import { useDownloadAuthStore } from '../stores/downloadAuthStore'
import type { DownloadProvider } from '../types'
import { isLocalDeployment } from '../utils/deployment'
const { provider, initiallyExpanded = false } = defineProps<{
provider: DownloadProvider
initiallyExpanded?: boolean
}>()
const store = useDownloadAuthStore()
const { copyToClipboard } = useCopyToClipboard()
const config = computed(() => getProviderConfig(provider))
const snippet = computed(() => envExportSnippet(provider))
const primaryEnvVar = computed(() => config.value.envVars[0])
const altEnvVar = computed(() => config.value.envVars[1])
const isLocal = isLocalDeployment()
const status = computed(() => store.statusFor(provider))
const isAuthenticated = computed(() => store.isAuthenticated(provider))
const loggedInViaOAuth = computed(() => !!status.value?.logged_in)
const envKeyPresent = computed(() => !!status.value?.env_key_present)
const isLoading = computed(() => store.isLoading)
const showInstructions = ref(initiallyExpanded && !isAuthenticated.value)
const isLoggingIn = ref(false)
const loginFailed = ref(false)
async function onLogin() {
isLoggingIn.value = true
loginFailed.value = false
try {
const outcome = await store.login(provider)
if (outcome === 'needs_env_key') showInstructions.value = true
if (outcome === 'failed') {
loginFailed.value = true
showInstructions.value = true
}
} finally {
isLoggingIn.value = false
}
}
async function onLogout() {
await store.logout(provider)
}
async function recheck() {
await store.fetchStatus()
}
</script>

View File

@@ -0,0 +1,359 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { DownloadStatus } from '../types'
import ModelDownloadRow from './ModelDownloadRow.vue'
const mockPause = vi.fn()
const mockResume = vi.fn()
const mockCancel = vi.fn()
const mockRaisePriority = vi.fn()
const mockRemove = vi.fn()
vi.mock('../composables/useModelDownloadActions', () => ({
useModelDownloadActions: () => ({
pause: mockPause,
resume: mockResume,
cancel: mockCancel,
raisePriority: mockRaisePriority,
remove: mockRemove,
toastError: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function createDownload(
overrides: Partial<DownloadStatus> = {}
): DownloadStatus {
return {
download_id: 'd1',
model_id: 'loras/x.safetensors',
url: 'https://huggingface.co/org/x.safetensors',
status: 'active',
priority: 0,
total_bytes: 2048,
bytes_done: 1024,
progress: 0.5,
speed_bps: 512,
eta_seconds: 125,
segments: null,
error: null,
created_at: 0,
updated_at: 0,
...overrides
}
}
function mountRow(
download: DownloadStatus,
onOpenAuth?: (provider: string | undefined) => void
) {
return render(ModelDownloadRow, {
props: {
download,
...(onOpenAuth ? { onOpenAuth } : {})
},
global: { plugins: [i18n] }
})
}
describe('ModelDownloadRow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('splits the model id into directory and filename', () => {
mountRow(createDownload({ model_id: 'loras/x.safetensors' }))
expect(screen.getByText('x.safetensors')).toBeInTheDocument()
expect(screen.getByText('loras')).toBeInTheDocument()
})
it('renders an empty directory when the model id has no folder', () => {
mountRow(createDownload({ model_id: 'x.safetensors' }))
expect(screen.getByText('x.safetensors')).toBeInTheDocument()
})
it('formats the meta line with percent, size, speed, and eta while active', () => {
mountRow(createDownload({ status: 'active' }))
expect(
screen.getByText('50% · 1 KB / 2 KB · 512 B/s · 2:05')
).toBeInTheDocument()
})
it('renders an empty meta line when no progress metrics are known yet', () => {
mountRow(
createDownload({
status: 'queued',
progress: null,
total_bytes: null,
bytes_done: 0,
speed_bps: null,
eta_seconds: null
})
)
expect(screen.getByTestId('meta-line')).toBeEmptyDOMElement()
})
it('omits the eta once a download is no longer active', () => {
mountRow(
createDownload({
status: 'paused',
progress: 0.5,
eta_seconds: 125
})
)
expect(screen.getByText('50% · 1 KB / 2 KB · 512 B/s')).toBeInTheDocument()
})
describe('progress bar visibility', () => {
it('shows a progress bar for an active download with known progress', () => {
mountRow(createDownload({ status: 'active', progress: 0.5 }))
expect(screen.getByTestId('progress-bar')).toBeInTheDocument()
})
it('hides the progress bar for a cancelled download', () => {
mountRow(createDownload({ status: 'cancelled', progress: 0.5 }))
expect(screen.queryByTestId('progress-bar')).not.toBeInTheDocument()
expect(screen.getByText('Cancelled')).toBeInTheDocument()
})
})
describe('action buttons by status', () => {
it('shows pause and cancel for a queued download, plus raise priority', () => {
mountRow(createDownload({ status: 'queued' }))
expect(screen.getByTitle('Raise priority')).toBeInTheDocument()
expect(screen.getByTitle('Pause')).toBeInTheDocument()
expect(screen.getByTitle('Cancel')).toBeInTheDocument()
expect(screen.queryByTitle('Resume')).not.toBeInTheDocument()
expect(screen.queryByTitle('Remove from list')).not.toBeInTheDocument()
})
it('shows only resume for a failed download without an auth error', () => {
mountRow(createDownload({ status: 'failed', error: 'disk full' }))
expect(screen.getByTitle('Resume')).toBeInTheDocument()
expect(screen.queryByTitle('Pause')).not.toBeInTheDocument()
expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument()
expect(screen.queryByTitle('Remove from list')).not.toBeInTheDocument()
expect(
screen.queryByTitle('Set up download access')
).not.toBeInTheDocument()
})
it('shows the remove action for terminal downloads', () => {
mountRow(createDownload({ status: 'completed' }))
expect(screen.getByTitle('Remove from list')).toBeInTheDocument()
expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument()
expect(screen.queryByTitle('Resume')).not.toBeInTheDocument()
})
})
describe('auth errors', () => {
it('shows the auth button and a host-specific hint, emitting the provider', async () => {
const onOpenAuth = vi.fn()
mountRow(
createDownload({
status: 'failed',
url: 'https://huggingface.co/org/x.safetensors',
error: '401 Unauthorized'
}),
onOpenAuth
)
expect(
screen.getByText(
'huggingface.co needs authentication. Set up download access, then resume.'
)
).toBeInTheDocument()
await userEvent.click(screen.getByTitle('Set up download access'))
expect(onOpenAuth).toHaveBeenCalledWith('huggingface')
})
it('falls back to a hostless hint when the url cannot be parsed', () => {
mountRow(
createDownload({
status: 'failed',
url: 'not-a-url',
error: '403 forbidden'
})
)
expect(
screen.getByText(
'This download needs authentication. Set up download access, then resume.'
)
).toBeInTheDocument()
})
it('shows the raw error message for a non-auth failure', () => {
mountRow(createDownload({ status: 'failed', error: 'disk full' }))
expect(screen.getByText('disk full')).toBeInTheDocument()
expect(
screen.queryByTitle('Set up download access')
).not.toBeInTheDocument()
})
it('hides a leftover error while the download is not yet terminal', () => {
mountRow(createDownload({ status: 'active', error: 'disk full' }))
expect(screen.queryByText('disk full')).not.toBeInTheDocument()
})
})
describe('gated models', () => {
const gatedError =
'https://huggingface.co/black-forest-labs/FLUX.2-dev/blob/main/vae/diffusion_pytorch_model.safetensors is a gated model — Access to model black-forest-labs/FLUX.2-dev is restricted. You must have access to it and be authenticated to access it.'
it('shows the gated hint, auth button, and a link to accept the license on the model page', () => {
mountRow(
createDownload({
status: 'failed',
url: 'https://huggingface.co/black-forest-labs/FLUX.2-dev/blob/main/vae/diffusion_pytorch_model.safetensors',
error: gatedError
})
)
expect(
screen.getByText(
"This model is gated. Accept its license on the model's page, set up download access, then resume."
)
).toBeInTheDocument()
expect(screen.getByTitle('Set up download access')).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Accept license' })
expect(link).toHaveAttribute(
'href',
'https://huggingface.co/black-forest-labs/FLUX.2-dev'
)
})
it('derives the model page from the error when the download url is a cdn link', () => {
mountRow(
createDownload({
status: 'failed',
url: 'https://cas-bridge.xethub.hf.co/xet-bridge-us/abc/def',
error: gatedError
})
)
expect(
screen.getByRole('link', { name: 'Accept license' })
).toHaveAttribute(
'href',
'https://huggingface.co/black-forest-labs/FLUX.2-dev'
)
})
it('hides the raw backend error text for gated failures', () => {
mountRow(
createDownload({
status: 'failed',
url: 'https://huggingface.co/black-forest-labs/FLUX.2-dev/blob/main/model.safetensors',
error: gatedError
})
)
expect(screen.queryByText(gatedError)).not.toBeInTheDocument()
})
it('shows the raw 404 message instead of the gated hint even though HF appends a generic gated-repo suggestion', () => {
const notFoundError =
'404 Client Error. (Request ID: abc123)\n\nRepository Not Found for url: https://huggingface.co/org/renamed-model/resolve/main/model.safetensors.\nPlease make sure you specified the correct `repo_id` and `repo_type`.\nIf you are trying to access a private or gated repo, make sure you are authenticated and your Access Token has the right permissions.'
mountRow(
createDownload({
status: 'failed',
url: 'https://huggingface.co/org/renamed-model/resolve/main/model.safetensors',
error: notFoundError
})
)
expect(
screen.queryByText(
"This model is gated. Accept its license on the model's page, set up download access, then resume."
)
).not.toBeInTheDocument()
expect(screen.getByText(notFoundError)).toBeInTheDocument()
})
it('omits the accept-license link when no huggingface url is present', () => {
mountRow(
createDownload({
status: 'failed',
url: 'https://example.com/model.safetensors',
error: 'Access to this model is restricted, request access.'
})
)
expect(
screen.queryByRole('link', { name: 'Accept license' })
).not.toBeInTheDocument()
})
})
describe('user actions', () => {
it('pauses on click', async () => {
const download = createDownload({ status: 'active' })
mountRow(download)
await userEvent.click(screen.getByTitle('Pause'))
expect(mockPause).toHaveBeenCalledWith(download)
})
it('resumes on click', async () => {
const download = createDownload({ status: 'paused' })
mountRow(download)
await userEvent.click(screen.getByTitle('Resume'))
expect(mockResume).toHaveBeenCalledWith(download)
})
it('cancels on click', async () => {
const download = createDownload({ status: 'active' })
mountRow(download)
await userEvent.click(screen.getByTitle('Cancel'))
expect(mockCancel).toHaveBeenCalledWith(download)
})
it('raises priority by 1 on click', async () => {
const download = createDownload({ status: 'queued', priority: 2 })
mountRow(download)
await userEvent.click(screen.getByTitle('Raise priority'))
expect(mockRaisePriority).toHaveBeenCalledWith(download, 1)
})
it('removes the download on click', async () => {
const download = createDownload({
download_id: 'd1',
status: 'completed'
})
mountRow(download)
await userEvent.click(screen.getByTitle('Remove from list'))
expect(mockRemove).toHaveBeenCalledWith(download)
})
})
})

View File

@@ -0,0 +1,265 @@
<template>
<div
:class="
cn(
'relative flex flex-col gap-1 overflow-hidden rounded-lg border border-border-default bg-secondary-background px-3 py-2',
isCancelled && 'opacity-60'
)
"
>
<div
v-if="showProgressBar"
:class="progressBarContainerClass"
data-testid="progress-bar"
>
<div :class="progressBarPrimaryClass" :style="barStyle" />
</div>
<div class="relative flex items-center gap-2">
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-medium text-base-foreground">
{{ filename }}
</span>
<span class="truncate text-xs text-muted-foreground">
{{ directory }}
</span>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<template v-if="canRaisePriority">
<Button
variant="textonly"
size="icon"
:title="$t('modelManager.raisePriority')"
@click="actions.raisePriority(download, 1)"
>
<i class="icon-[lucide--chevron-up] size-4" />
</Button>
</template>
<Button
v-if="canPause"
variant="textonly"
size="icon"
:title="$t('g.pause')"
@click="actions.pause(download)"
>
<i class="icon-[lucide--pause] size-4" />
</Button>
<Button
v-if="isAuthError"
variant="textonly"
size="icon"
:title="$t('modelManager.setUpDownloadAccess')"
@click="emit('openAuth', provider)"
>
<i class="icon-[lucide--key-round] size-4" />
</Button>
<Button
v-if="canResume"
variant="textonly"
size="icon"
:title="$t('modelManager.resume')"
@click="actions.resume(download)"
>
<i class="icon-[lucide--play] size-4" />
</Button>
<Button
v-if="canCancel"
variant="textonly"
size="icon"
:title="$t('g.cancel')"
@click="actions.cancel(download)"
>
<i class="icon-[lucide--x] size-4 text-red-400" />
</Button>
<Button
v-if="isTerminal"
variant="textonly"
size="icon"
:title="$t('modelManager.removeFromList')"
@click="actions.remove(download)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
<div
class="relative flex items-center justify-between gap-2 text-xs text-muted-foreground"
>
<span class="flex items-center gap-1">
<i v-if="isCancelled" class="icon-[lucide--ban] size-3.5" />
{{ statusLabel }}
</span>
<span class="truncate" data-testid="meta-line">{{ metaLine }}</span>
</div>
<p
v-if="isGatedModel"
class="relative text-xs wrap-break-word text-amber-400"
>
{{ $t('modelManager.gatedModelHint') }}
<a
v-if="modelPageUrl"
:href="modelPageUrl"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
{{ $t('modelManager.openModelPage') }}
</a>
</p>
<p
v-else-if="isAuthError"
class="relative text-xs wrap-break-word text-amber-400"
>
{{
host
? $t('modelManager.authErrorHint', { host })
: $t('modelManager.authErrorHintNoHost')
}}
</p>
<p
v-else-if="isFailed && download.error"
class="relative text-xs wrap-break-word text-red-400"
>
{{ download.error }}
</p>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { formatSize } from '@/utils/formatUtil'
import { useModelDownloadActions } from '../composables/useModelDownloadActions'
import { providerForUrl } from '../downloadAuthProviders'
import { downloadProgressFraction } from '../stores/modelDownloadStore'
import type { DownloadProvider, DownloadStatus } from '../types'
import { directoryOf, filenameOf } from '../utils/modelId'
const { download } = defineProps<{ download: DownloadStatus }>()
const emit = defineEmits<{
openAuth: [provider: DownloadProvider | undefined]
}>()
const { t } = useI18n()
const actions = useModelDownloadActions()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressPercentStyle
} = useProgressBarBackground()
const directory = computed(() => directoryOf(download.model_id))
const filename = computed(() => filenameOf(download.model_id))
const host = computed(() => {
try {
return new URL(download.url).hostname
} catch {
return ''
}
})
const provider = computed(() => providerForUrl(download.url))
const percent = computed(() => {
const fraction = downloadProgressFraction(download)
return fraction == null ? undefined : Math.round(fraction * 100)
})
const barStyle = computed(() => progressPercentStyle(percent.value))
const isTerminal = computed(() =>
['completed', 'cancelled'].includes(download.status)
)
const isCancelled = computed(() => download.status === 'cancelled')
const showProgressBar = computed(
() =>
download.status !== 'failed' &&
!isCancelled.value &&
percent.value !== undefined
)
const canPause = computed(() => ['queued', 'active'].includes(download.status))
const canResume = computed(() => ['paused', 'failed'].includes(download.status))
const canCancel = computed(
() => !isTerminal.value && download.status !== 'failed'
)
const canRaisePriority = computed(() => download.status === 'queued')
// Matches an explicit statement that the model is gated, not HF's generic
// 404 boilerplate that merely suggests a missing file "might" be private or
// gated ("...If you are trying to access a private or gated repo, make sure
// you are authenticated and your Access Token has the right permissions."),
// which would otherwise mask the real 404 message behind both this hint and
// the auth-error hint below (that boilerplate also mentions "Access Token").
const GATED_MODEL_PATTERN =
/is a gated (model|repo)|access .* is restricted|must have access|request access to/i
const NOT_FOUND_PATTERN = /\b404\b|not found/i
const AUTH_ERROR_PATTERN =
/api key|credential|token|unauthor|forbidden|\b401\b|\b403\b/i
const isFailed = computed(() => download.status === 'failed')
const looksLikeNotFound = computed(() =>
NOT_FOUND_PATTERN.test(download.error ?? '')
)
const isGatedModel = computed(
() =>
isFailed.value &&
!looksLikeNotFound.value &&
!!download.error?.match(GATED_MODEL_PATTERN)
)
const isAuthError = computed(
() =>
isFailed.value &&
!looksLikeNotFound.value &&
(isGatedModel.value || !!download.error?.match(AUTH_ERROR_PATTERN))
)
const HF_MODEL_URL_PATTERN =
/https?:\/\/huggingface\.co\/([^/\s?#]+)\/([^/\s?#]+)/i
const modelPageUrl = computed(() => {
const match = `${download.url} ${download.error ?? ''}`.match(
HF_MODEL_URL_PATTERN
)
if (!match) return ''
const [, owner, repo] = match
return `https://huggingface.co/${owner}/${repo}`
})
const statusLabel = computed(() => t(`modelManager.status.${download.status}`))
const metaLine = computed(() => {
const parts: string[] = []
if (percent.value !== undefined) parts.push(`${percent.value}%`)
if (download.total_bytes != null) {
parts.push(
`${formatSize(download.bytes_done)} / ${formatSize(download.total_bytes)}`
)
}
if (download.speed_bps) parts.push(`${formatSize(download.speed_bps)}/s`)
if (download.eta_seconds != null && download.status === 'active') {
parts.push(formatEta(download.eta_seconds))
}
return parts.join(' · ')
})
function formatEta(seconds: number): string {
const total = Math.max(0, Math.round(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
const paddedSecs = secs.toString().padStart(2, '0')
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${paddedSecs}`
}
return `${minutes}:${paddedSecs}`
}
</script>

View File

@@ -0,0 +1,179 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { DownloadStatus } from '../types'
import ModelManagerSidebarTab from './ModelManagerSidebarTab.vue'
const mockHydrate = vi.fn()
const mockClearHistory = vi.fn()
const mockStore = reactive({
downloadList: ref<DownloadStatus[]>([]),
activeDownloads: ref<DownloadStatus[]>([]),
historyDownloads: ref<DownloadStatus[]>([]),
hydrate: mockHydrate
})
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => mockStore
}))
vi.mock('../composables/useModelDownloadActions', () => ({
useModelDownloadActions: () => ({ clearHistory: mockClearHistory })
}))
vi.mock('./ModelDownloadRow.vue', () => ({
default: {
name: 'ModelDownloadRow',
props: ['download'],
emits: ['openAuth'],
template:
'<div><span>{{ download.download_id }}</span>' +
"<button @click=\"$emit('openAuth', 'huggingface')\">open-auth</button></div>"
}
}))
vi.mock('./AddModelByUrlDialog.vue', () => ({
default: {
name: 'AddModelByUrlDialog',
props: ['open'],
emits: ['authRequired'],
template: '<div data-testid="add-model-dialog">{{ open }}</div>'
}
}))
vi.mock('./DownloadAuthDialog.vue', () => ({
default: {
name: 'DownloadAuthDialog',
props: ['open', 'focusProvider'],
template:
'<div data-testid="auth-dialog">{{ open }}:{{ focusProvider }}</div>'
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template: '<div><slot name="tool-buttons" /><slot name="body" /></div>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function createDownload(
overrides: Partial<DownloadStatus> = {}
): DownloadStatus {
return {
download_id: 'd1',
model_id: 'loras/x.safetensors',
url: 'https://huggingface.co/org/x.safetensors',
status: 'active',
priority: 0,
total_bytes: null,
bytes_done: 0,
progress: null,
speed_bps: null,
eta_seconds: null,
segments: null,
error: null,
created_at: 0,
updated_at: 0,
...overrides
}
}
function mountTab() {
return render(ModelManagerSidebarTab, { global: { plugins: [i18n] } })
}
describe('ModelManagerSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.downloadList = []
mockStore.activeDownloads = []
mockStore.historyDownloads = []
})
it('hydrates the store on mount', () => {
mountTab()
expect(mockHydrate).toHaveBeenCalled()
})
it('shows the empty state when there are no downloads', () => {
mountTab()
expect(screen.getByText('No downloads yet')).toBeInTheDocument()
})
it('renders active downloads under the Active section', () => {
const download = createDownload({ download_id: 'd1' })
mockStore.activeDownloads = [download]
mockStore.downloadList = [download]
mountTab()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('d1')).toBeInTheDocument()
expect(screen.queryByText('No downloads yet')).not.toBeInTheDocument()
})
it('renders history downloads under the History section with a clear action', async () => {
const download = createDownload({ download_id: 'd2' })
mockStore.historyDownloads = [download]
mockStore.downloadList = [download]
mountTab()
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('d2')).toBeInTheDocument()
await userEvent.click(screen.getByText('Clear history'))
expect(mockClearHistory).toHaveBeenCalled()
})
it('opens the add-model dialog from the toolbar button', async () => {
mountTab()
expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('false')
await userEvent.click(screen.getByTitle('Add model'))
expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('true')
})
it('opens the add-model dialog from the empty state button', async () => {
mountTab()
await userEvent.click(screen.getByText('Add model'))
expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('true')
})
it('opens the download-auth dialog with no focused provider from the toolbar button', async () => {
mountTab()
await userEvent.click(screen.getByTitle('Download access'))
expect(screen.getByTestId('auth-dialog')).toHaveTextContent('true:')
})
it('opens the download-auth dialog focused on the row provider', async () => {
const download = createDownload({
download_id: 'd1',
model_id: 'loras/x.safetensors'
})
mockStore.activeDownloads = [download]
mockStore.downloadList = [download]
mountTab()
await userEvent.click(screen.getByText('open-auth'))
expect(screen.getByTestId('auth-dialog')).toHaveTextContent(
'true:huggingface'
)
})
})

View File

@@ -0,0 +1,101 @@
<template>
<SidebarTabTemplate :title="$t('modelManager.title')">
<template #tool-buttons>
<Button
variant="textonly"
size="icon"
:title="$t('modelManager.downloadAuth.title')"
@click="openAuth()"
>
<i class="icon-[lucide--key-round] size-4" />
</Button>
<Button
variant="textonly"
size="icon"
:title="$t('modelManager.addModel')"
@click="addOpen = true"
>
<i class="icon-[lucide--plus] size-4" />
</Button>
</template>
<template #body>
<div class="flex flex-col gap-4 p-3">
<section v-if="activeDownloads.length" class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-muted-foreground uppercase">
{{ $t('modelManager.active') }}
</h3>
<ModelDownloadRow
v-for="download in activeDownloads"
:key="download.download_id"
:download
@open-auth="openAuth"
/>
</section>
<section v-if="historyDownloads.length" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold text-muted-foreground uppercase">
{{ $t('modelManager.history') }}
</h3>
<Button variant="link" size="sm" @click="actions.clearHistory()">
{{ $t('modelManager.clearHistory') }}
</Button>
</div>
<ModelDownloadRow
v-for="download in historyDownloads"
:key="download.download_id"
:download
@open-auth="openAuth"
/>
</section>
<div
v-if="!store.downloadList.length"
class="flex flex-col items-center gap-3 py-10 text-center text-muted-foreground"
>
<i class="icon-[lucide--download] size-8" />
<p class="text-sm">{{ $t('modelManager.empty') }}</p>
<Button variant="primary" size="sm" @click="addOpen = true">
{{ $t('modelManager.addModel') }}
</Button>
</div>
</div>
</template>
</SidebarTabTemplate>
<AddModelByUrlDialog v-model:open="addOpen" @auth-required="openAuth" />
<DownloadAuthDialog v-model:open="authOpen" :focus-provider="focusProvider" />
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import AddModelByUrlDialog from './AddModelByUrlDialog.vue'
import DownloadAuthDialog from './DownloadAuthDialog.vue'
import ModelDownloadRow from './ModelDownloadRow.vue'
import { useModelDownloadActions } from '../composables/useModelDownloadActions'
import { useModelDownloadStore } from '../stores/modelDownloadStore'
import type { DownloadProvider } from '../types'
const store = useModelDownloadStore()
const actions = useModelDownloadActions()
const { activeDownloads, historyDownloads } = storeToRefs(store)
const addOpen = ref(false)
const authOpen = ref(false)
const focusProvider = ref<DownloadProvider | undefined>(undefined)
function openAuth(provider?: DownloadProvider) {
focusProvider.value = provider
authOpen.value = true
}
onMounted(() => {
void store.hydrate()
})
</script>

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as downloadApi from '../api/modelDownloadApi'
import { useModelAvailability } from './useModelAvailability'
vi.mock('../api/modelDownloadApi', () => ({
checkAvailability: vi.fn()
}))
describe('useModelAvailability', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('short-circuits without calling the API for an empty map', async () => {
const { check, results } = useModelAvailability()
await check({})
expect(downloadApi.checkAvailability).not.toHaveBeenCalled()
expect(results.value).toEqual({})
})
it('stores the availability map from the response', async () => {
vi.mocked(downloadApi.checkAvailability).mockResolvedValue({
models: {
'loras/x.safetensors': { state: 'available', url_allowed: true }
}
})
const { check, results, isChecking } = useModelAvailability()
await check({ 'loras/x.safetensors': 'https://h.co/x' })
expect(results.value['loras/x.safetensors']).toEqual({
state: 'available',
url_allowed: true
})
expect(isChecking.value).toBe(false)
})
it('captures errors and resets the checking flag', async () => {
vi.mocked(downloadApi.checkAvailability).mockRejectedValue(
new Error('boom')
)
const { check, error, isChecking } = useModelAvailability()
await expect(check({ 'loras/x.safetensors': 'u' })).rejects.toThrow('boom')
expect(error.value).toBeInstanceOf(Error)
expect(isChecking.value).toBe(false)
})
})

View File

@@ -0,0 +1,41 @@
import { ref, shallowRef } from 'vue'
import { checkAvailability } from '../api/modelDownloadApi'
import type { AvailabilityEntry } from '../types'
/**
* Bulk-checks whether the models declared by a workflow are present,
* downloading, or missing. Pass a `model_id -> source URL` map and badge
* each model from the returned entries.
*/
export function useModelAvailability() {
const results = shallowRef<Record<string, AvailabilityEntry>>({})
const isChecking = ref(false)
const error = ref<unknown>(null)
async function check(models: Record<string, string>) {
if (Object.keys(models).length === 0) {
results.value = {}
return results.value
}
isChecking.value = true
error.value = null
try {
const response = await checkAvailability(models)
results.value = response.models
return response.models
} catch (e) {
error.value = e
throw e
} finally {
isChecking.value = false
}
}
return {
results,
isChecking,
error,
check
}
}

View File

@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import type { DownloadStatus } from '../types'
import { DownloadApiError } from '../types'
import { useModelDownloadActions } from './useModelDownloadActions'
const mockStore = {
pause: vi.fn(),
resume: vi.fn(),
cancel: vi.fn(),
setPriority: vi.fn(),
hydrate: vi.fn()
}
const mockToastAdd = vi.fn()
const mockCloseDialog = vi.fn()
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => mockStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: mockCloseDialog })
}))
vi.mock('@/components/dialog/confirm/confirmDialog')
const mockShowConfirmDialog = vi.mocked(showConfirmDialog)
interface CapturedConfirmOptions {
footerProps?: {
confirmVariant?: string
onConfirm?: () => void | Promise<void>
onCancel?: () => void
}
}
function capturedOptions(): CapturedConfirmOptions {
return mockShowConfirmDialog.mock
.calls[0][0] as unknown as CapturedConfirmOptions
}
function createDownload(
overrides: Partial<DownloadStatus> = {}
): DownloadStatus {
return {
download_id: 'd1',
model_id: 'loras/x.safetensors',
url: 'https://huggingface.co/x.safetensors',
status: 'active',
priority: 0,
total_bytes: null,
bytes_done: 0,
progress: null,
speed_bps: null,
eta_seconds: null,
segments: null,
error: null,
created_at: 0,
updated_at: 0,
...overrides
}
}
describe('useModelDownloadActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockShowConfirmDialog.mockReturnValue(
{} as ReturnType<typeof showConfirmDialog>
)
})
it('pauses a download by id', async () => {
mockStore.pause.mockResolvedValue(undefined)
const { pause } = useModelDownloadActions()
await pause(createDownload({ download_id: 'd1' }))
expect(mockStore.pause).toHaveBeenCalledWith('d1')
})
it('resumes a download by id', async () => {
mockStore.resume.mockResolvedValue(undefined)
const { resume } = useModelDownloadActions()
await resume(createDownload({ download_id: 'd1' }))
expect(mockStore.resume).toHaveBeenCalledWith('d1')
})
it('raises priority relative to the current value', async () => {
mockStore.setPriority.mockResolvedValue(undefined)
const { raisePriority } = useModelDownloadActions()
await raisePriority(createDownload({ download_id: 'd1', priority: 2 }), 1)
expect(mockStore.setPriority).toHaveBeenCalledWith('d1', 3)
})
it('shows an error toast and re-hydrates the store when an action fails', async () => {
mockStore.pause.mockRejectedValue(new Error('offline'))
mockStore.hydrate.mockResolvedValue(undefined)
const { pause } = useModelDownloadActions()
await pause(createDownload())
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error', detail: 'offline' })
)
expect(mockStore.hydrate).toHaveBeenCalled()
})
it('uses the DownloadApiError message in the toast', async () => {
mockStore.pause.mockRejectedValue(
new DownloadApiError('nope', 'URL_NOT_ALLOWED', 400)
)
mockStore.hydrate.mockResolvedValue(undefined)
const { pause } = useModelDownloadActions()
await pause(createDownload())
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'nope' })
)
})
it('swallows a failed re-hydrate after an action error', async () => {
mockStore.pause.mockRejectedValue(new Error('offline'))
mockStore.hydrate.mockRejectedValue(new Error('still offline'))
const { pause } = useModelDownloadActions()
await expect(pause(createDownload())).resolves.toBeUndefined()
})
describe('cancel', () => {
it('opens a destructive confirm dialog and only cancels on confirm', async () => {
mockStore.cancel.mockResolvedValue(undefined)
const { cancel } = useModelDownloadActions()
cancel(createDownload({ download_id: 'd1' }))
expect(capturedOptions().footerProps?.confirmVariant).toBe('destructive')
await capturedOptions().footerProps?.onConfirm?.()
expect(mockStore.cancel).toHaveBeenCalledWith('d1')
expect(mockCloseDialog).toHaveBeenCalled()
})
it('does not cancel when the confirm dialog is dismissed', () => {
const { cancel } = useModelDownloadActions()
cancel(createDownload({ download_id: 'd1' }))
capturedOptions().footerProps?.onCancel?.()
expect(mockStore.cancel).not.toHaveBeenCalled()
expect(mockCloseDialog).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,97 @@
import { useI18n } from 'vue-i18n'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelDownloadStore } from '../stores/modelDownloadStore'
import type { DownloadStatus } from '../types'
import { DownloadApiError } from '../types'
/**
* Wraps download store mutations with user feedback: optimistic-friendly error
* toasts and a confirmation prompt for the destructive cancel action (which
* deletes the partial file).
*/
export function useModelDownloadActions() {
const store = useModelDownloadStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
function toastError(error: unknown) {
const detail =
error instanceof DownloadApiError || error instanceof Error
? error.message
: String(error)
useToastStore().add({
severity: 'error',
summary: t('modelManager.actionFailed'),
detail,
life: 5000
})
}
async function run(action: () => Promise<void>) {
try {
await action()
} catch (error) {
toastError(error)
// The store applies optimistic status/priority patches before the API
// call; re-fetch the authoritative state so a failed mutation doesn't
// leave the row stuck in the wrong local state.
try {
await store.hydrate()
} catch {
// Server unreachable; the next poll will reconcile.
}
}
}
const pause = (download: DownloadStatus) =>
run(() => store.pause(download.download_id))
const resume = (download: DownloadStatus) =>
run(() => store.resume(download.download_id))
const raisePriority = (download: DownloadStatus, delta: number) =>
run(() =>
store.setPriority(download.download_id, download.priority + delta)
)
const remove = (download: DownloadStatus) =>
run(() => store.remove(download.download_id))
const clearHistory = () => run(() => store.clearHistory())
function cancel(download: DownloadStatus) {
const dialog = showConfirmDialog({
headerProps: { title: t('modelManager.cancelConfirmTitle') },
props: {
promptText: t(
'modelManager.cancelConfirmMessage',
{ name: download.model_id },
{ escapeParameter: false }
)
},
footerProps: {
confirmText: t('modelManager.cancelConfirm'),
confirmVariant: 'destructive' as const,
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: async () => {
dialogStore.closeDialog(dialog)
await run(() => store.cancel(download.download_id))
}
}
})
}
return {
pause,
resume,
cancel,
raisePriority,
remove,
clearHistory,
toastError
}
}

View File

@@ -0,0 +1,93 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CompletedDownload } from '../stores/modelDownloadStore'
import { useModelDownloadEffects } from './useModelDownloadEffects'
const mockLastCompletedDownload = ref<CompletedDownload | null>(null)
const mockRefreshModelFolder = vi.fn()
const mockRefreshMissingModels = vi.fn()
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => ({
get lastCompletedDownload() {
return mockLastCompletedDownload.value
}
})
}))
vi.mock('@/stores/modelStore', () => ({
useModelStore: () => ({ refreshModelFolder: mockRefreshModelFolder })
}))
vi.mock('@/platform/missingModel/missingModelStore', () => ({
useMissingModelStore: () => ({
refreshMissingModels: mockRefreshMissingModels
})
}))
describe('useModelDownloadEffects', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLastCompletedDownload.value = null
mockRefreshModelFolder.mockResolvedValue(undefined)
})
it('does nothing until a download completes', () => {
useModelDownloadEffects()
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
expect(mockRefreshMissingModels).not.toHaveBeenCalled()
})
it('refreshes the model folder and re-scans missing models on completion', async () => {
useModelDownloadEffects()
mockLastCompletedDownload.value = {
downloadId: 'd1',
modelId: 'loras/x.safetensors',
directory: 'loras',
timestamp: 1
}
await vi.waitFor(() => {
expect(mockRefreshModelFolder).toHaveBeenCalledWith('loras')
})
expect(mockRefreshMissingModels).toHaveBeenCalled()
})
it('skips the folder refresh when the directory is unknown', async () => {
useModelDownloadEffects()
mockLastCompletedDownload.value = {
downloadId: 'd1',
modelId: 'unknown.safetensors',
directory: '',
timestamp: 1
}
await vi.waitFor(() => {
expect(mockRefreshMissingModels).toHaveBeenCalled()
})
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
})
it('still re-scans missing models when the folder refresh fails', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockRefreshModelFolder.mockRejectedValue(new Error('boom'))
useModelDownloadEffects()
mockLastCompletedDownload.value = {
downloadId: 'd1',
modelId: 'loras/x.safetensors',
directory: 'loras',
timestamp: 1
}
await vi.waitFor(() => {
expect(mockRefreshMissingModels).toHaveBeenCalled()
})
expect(consoleWarn).toHaveBeenCalled()
consoleWarn.mockRestore()
})
})

View File

@@ -0,0 +1,45 @@
import { watch } from 'vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useModelStore } from '@/stores/modelStore'
import { useModelDownloadStore } from '../stores/modelDownloadStore'
/**
* Side effects that run when a server-side download finishes: refresh the
* affected model folder so the file is recognized, and re-scan missing models
* so node errors for the now-present model clear automatically.
*
* Mounted once at the app root so it runs regardless of whether the Model
* Manager panel is open.
*/
export function useModelDownloadEffects() {
const store = useModelDownloadStore()
watch(
() => store.lastCompletedDownload,
async (completed) => {
if (!completed) return
if (completed.directory) {
try {
await useModelStore().refreshModelFolder(completed.directory)
} catch (error) {
console.warn(
'[ModelManager] Failed to refresh model folder after download',
error
)
}
}
try {
await useMissingModelStore().refreshMissingModels()
} catch (error) {
console.warn(
'[ModelManager] Failed to re-scan missing models after download',
error
)
}
}
)
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
const mockActiveDownloadCount = { value: 0 }
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => ({
get activeDownloadCount() {
return mockActiveDownloadCount.value
}
})
}))
vi.mock('../components/ModelManagerSidebarTab.vue', () => ({
default: { name: 'ModelManagerSidebarTab' }
}))
import { useModelManagerSidebarTab } from './useModelManagerSidebarTab'
describe('useModelManagerSidebarTab', () => {
it('returns the expected sidebar tab extension shape', () => {
const tab = useModelManagerSidebarTab()
expect(tab.id).toBe('model-manager')
expect(tab.type).toBe('vue')
expect(tab.title).toBe('modelManager.title')
expect(tab.tooltip).toBe('modelManager.title')
expect(tab.label).toBe('modelManager.title')
})
it('shows no badge when there are no active downloads', () => {
mockActiveDownloadCount.value = 0
const tab = useModelManagerSidebarTab()
expect(typeof tab.iconBadge).toBe('function')
expect((tab.iconBadge as () => string | null)()).toBeNull()
})
it('shows the active download count as a badge', () => {
mockActiveDownloadCount.value = 3
const tab = useModelManagerSidebarTab()
expect((tab.iconBadge as () => string | null)()).toBe('3')
})
})

View File

@@ -0,0 +1,22 @@
import { markRaw } from 'vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import ModelManagerSidebarTab from '../components/ModelManagerSidebarTab.vue'
import { useModelDownloadStore } from '../stores/modelDownloadStore'
export function useModelManagerSidebarTab(): SidebarTabExtension {
return {
id: 'model-manager',
icon: 'icon-[lucide--download]',
title: 'modelManager.title',
tooltip: 'modelManager.title',
label: 'modelManager.title',
component: markRaw(ModelManagerSidebarTab),
type: 'vue',
iconBadge: () => {
const count = useModelDownloadStore().activeDownloadCount
return count > 0 ? count.toString() : null
}
}
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
DOWNLOAD_PROVIDER_CONFIGS,
envExportSnippet,
getProviderConfig,
providerForHost,
providerForUrl
} from './downloadAuthProviders'
describe('downloadAuthProviders', () => {
it('exposes a config for every provider with a primary env var', () => {
expect(DOWNLOAD_PROVIDER_CONFIGS.map((c) => c.id)).toEqual([
'huggingface',
'civitai'
])
for (const config of DOWNLOAD_PROVIDER_CONFIGS) {
expect(config.envVars.length).toBeGreaterThan(0)
}
})
describe('providerForHost', () => {
it('matches the exact host case-insensitively', () => {
expect(providerForHost('HuggingFace.co')).toBe('huggingface')
expect(providerForHost('civitai.com')).toBe('civitai')
})
it('matches subdomains of a provider host', () => {
expect(providerForHost('cdn-lfs.huggingface.co')).toBe('huggingface')
})
it('returns undefined for unknown or empty hosts', () => {
expect(providerForHost('example.com')).toBeUndefined()
expect(providerForHost('')).toBeUndefined()
expect(providerForHost(null)).toBeUndefined()
})
it('does not match a lookalike host that merely contains the base', () => {
expect(providerForHost('nothuggingface.co')).toBeUndefined()
})
})
describe('providerForUrl', () => {
it('derives the provider from a full url', () => {
expect(
providerForUrl('https://huggingface.co/org/repo/model.safetensors')
).toBe('huggingface')
})
it('returns undefined for an unparseable url', () => {
expect(providerForUrl('not a url')).toBeUndefined()
})
})
describe('envExportSnippet', () => {
it('uses the primary env var and a provider-appropriate placeholder', () => {
expect(envExportSnippet('huggingface')).toBe('export HF_TOKEN="hf_xxx"')
expect(envExportSnippet('civitai')).toBe(
'export CIVITAI_API_TOKEN="xxxx"'
)
})
})
it('reports gated capability per provider', () => {
expect(getProviderConfig('huggingface').canBeGated).toBe(true)
expect(getProviderConfig('civitai').canBeGated).toBe(false)
})
})

View File

@@ -0,0 +1,78 @@
import type { DownloadProvider } from './types'
import { DOWNLOAD_PROVIDERS } from './types'
import { hostFromUrl } from './utils/modelId'
interface DownloadProviderConfig {
id: DownloadProvider
/** Brand name, shown as-is (proper noun, not translated). */
label: string
/** Primary host that maps to this provider. */
host: string
/** Where the user creates a token / API key. */
tokenUrl: string
/**
* Server-side env vars that supply the API key, in resolution order (first
* match wins). The first entry is the one shown in copy-paste instructions.
*/
envVars: readonly [string, ...string[]]
/** Whether this provider can gate models behind a per-model access request. */
canBeGated: boolean
}
const PROVIDER_CONFIGS: Record<DownloadProvider, DownloadProviderConfig> = {
huggingface: {
id: 'huggingface',
label: 'HuggingFace',
host: 'huggingface.co',
tokenUrl: 'https://huggingface.co/settings/tokens',
envVars: ['HF_TOKEN', 'HUGGING_FACE_HUB_TOKEN'],
canBeGated: true
},
civitai: {
id: 'civitai',
label: 'Civitai',
host: 'civitai.com',
tokenUrl: 'https://civitai.com/user/account',
envVars: ['CIVITAI_API_TOKEN', 'CIVITAI_API_KEY'],
canBeGated: false
}
}
export const DOWNLOAD_PROVIDER_CONFIGS: DownloadProviderConfig[] =
DOWNLOAD_PROVIDERS.map((provider) => PROVIDER_CONFIGS[provider])
export function getProviderConfig(
provider: DownloadProvider
): DownloadProviderConfig {
return PROVIDER_CONFIGS[provider]
}
/**
* The `export FOO="..."` snippet shown in the env-var instructions, using the
* provider's primary env var.
*/
export function envExportSnippet(provider: DownloadProvider): string {
const { id, envVars } = PROVIDER_CONFIGS[provider]
const placeholder = id === 'huggingface' ? 'hf_xxx' : 'xxxx'
return `export ${envVars[0]}="${placeholder}"`
}
/**
* Maps a host (e.g. `huggingface.co`, `www.civitai.com`) to its provider, or
* `undefined` when no provider owns it. Subdomains match their parent host.
*/
export function providerForHost(
host: string | null | undefined
): DownloadProvider | undefined {
if (!host) return undefined
const normalized = host.toLowerCase()
return DOWNLOAD_PROVIDERS.find((provider) => {
const { host: base } = PROVIDER_CONFIGS[provider]
return normalized === base || normalized.endsWith(`.${base}`)
})
}
/** Maps a full URL to its provider, or `undefined` when unparseable/unknown. */
export function providerForUrl(url: string): DownloadProvider | undefined {
return providerForHost(hostFromUrl(url))
}

View File

@@ -0,0 +1,135 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as downloadApi from '../api/modelDownloadApi'
import type { ProviderAuthStatus } from '../types'
import { DownloadApiError } from '../types'
import { useDownloadAuthStore } from './downloadAuthStore'
vi.mock('../api/modelDownloadApi', () => ({
getDownloadAuth: vi.fn(),
startProviderLogin: vi.fn(),
logoutProvider: vi.fn()
}))
function status(
overrides: Partial<ProviderAuthStatus> = {}
): ProviderAuthStatus {
return {
provider: 'huggingface',
env_key_present: false,
logged_in: false,
login_in_progress: false,
...overrides
}
}
describe('useDownloadAuthStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('fetchStatus / isAuthenticated', () => {
it('stores the provider list and derives authentication', async () => {
vi.mocked(downloadApi.getDownloadAuth).mockResolvedValue([
status({ provider: 'huggingface', env_key_present: true }),
status({ provider: 'civitai', logged_in: true })
])
const store = useDownloadAuthStore()
await store.fetchStatus()
expect(store.isAuthenticated('huggingface')).toBe(true)
expect(store.isAuthenticated('civitai')).toBe(true)
})
it('treats a provider with neither env key nor login as unauthenticated', async () => {
vi.mocked(downloadApi.getDownloadAuth).mockResolvedValue([status()])
const store = useDownloadAuthStore()
await store.fetchStatus()
expect(store.isAuthenticated('huggingface')).toBe(false)
})
})
describe('login', () => {
it('falls back to env-key instructions when OAuth is not configured', async () => {
vi.mocked(downloadApi.startProviderLogin).mockRejectedValue(
new DownloadApiError('no client', 'OAUTH_NOT_CONFIGURED', 400)
)
const store = useDownloadAuthStore()
await expect(store.login('huggingface')).resolves.toBe('needs_env_key')
expect(window.open).not.toHaveBeenCalled()
})
it('reports failure for an unexpected login error', async () => {
vi.mocked(downloadApi.startProviderLogin).mockRejectedValue(
new Error('network down')
)
const store = useDownloadAuthStore()
await expect(store.login('huggingface')).resolves.toBe('failed')
})
it('opens the authorize url and resolves once the provider is logged in', async () => {
vi.useFakeTimers()
vi.mocked(downloadApi.startProviderLogin).mockResolvedValue({
authorize_url: 'https://auth.example/go'
})
vi.mocked(downloadApi.getDownloadAuth)
.mockResolvedValueOnce([status({ login_in_progress: true })])
.mockResolvedValueOnce([status({ logged_in: true })])
const store = useDownloadAuthStore()
const pending = store.login('huggingface')
await vi.advanceTimersByTimeAsync(2_000)
await vi.advanceTimersByTimeAsync(2_000)
await expect(pending).resolves.toBe('logged_in')
expect(window.open).toHaveBeenCalledWith(
'https://auth.example/go',
'_blank',
'noopener'
)
})
it('reports failure when the login stops progressing without logging in', async () => {
vi.useFakeTimers()
vi.mocked(downloadApi.startProviderLogin).mockResolvedValue({
authorize_url: 'https://auth.example/go'
})
vi.mocked(downloadApi.getDownloadAuth).mockResolvedValue([
status({ login_in_progress: false, logged_in: false })
])
const store = useDownloadAuthStore()
const pending = store.login('huggingface')
await vi.advanceTimersByTimeAsync(2_000)
await expect(pending).resolves.toBe('failed')
})
})
describe('logout', () => {
it('clears the token and refreshes status', async () => {
vi.mocked(downloadApi.logoutProvider).mockResolvedValue(undefined)
vi.mocked(downloadApi.getDownloadAuth).mockResolvedValue([status()])
const store = useDownloadAuthStore()
await store.logout('huggingface')
expect(downloadApi.logoutProvider).toHaveBeenCalledWith('huggingface')
expect(downloadApi.getDownloadAuth).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
getDownloadAuth,
logoutProvider,
startProviderLogin
} from '../api/modelDownloadApi'
import type { DownloadProvider, ProviderAuthStatus } from '../types'
import { DownloadApiError } from '../types'
const POLL_INTERVAL_MS = 2_000
const LOGIN_TIMEOUT_MS = 5 * 60 * 1_000
/**
* Result of an OAuth login attempt:
* - `logged_in` — a token was stored; the provider is now authenticated.
* - `needs_env_key` — OAuth is unavailable (not configured); the caller should
* fall back to the env-var API-key instructions.
* - `failed` — the login errored or timed out; the caller should offer a retry
* and the env-var instructions.
*/
type LoginOutcome = 'logged_in' | 'needs_env_key' | 'failed'
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const useDownloadAuthStore = defineStore('downloadAuth', () => {
const providers = ref<ProviderAuthStatus[]>([])
const isLoading = ref(false)
const statusByProvider = computed(
() => new Map(providers.value.map((p) => [p.provider, p]))
)
function statusFor(
provider: DownloadProvider
): ProviderAuthStatus | undefined {
return statusByProvider.value.get(provider)
}
function isAuthenticated(provider: DownloadProvider): boolean {
const status = statusFor(provider)
return !!status && (status.env_key_present || status.logged_in)
}
async function fetchStatus() {
isLoading.value = true
try {
providers.value = await getDownloadAuth()
} finally {
isLoading.value = false
}
}
async function pollUntilResolved(
provider: DownloadProvider
): Promise<LoginOutcome> {
const deadline = Date.now() + LOGIN_TIMEOUT_MS
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS)
await fetchStatus().catch(() => {})
const status = statusFor(provider)
if (status?.logged_in) return 'logged_in'
if (!status?.login_in_progress) return 'failed'
}
return 'failed'
}
async function login(provider: DownloadProvider): Promise<LoginOutcome> {
try {
const { authorize_url } = await startProviderLogin(provider)
window.open(authorize_url, '_blank', 'noopener')
} catch (error) {
if (!(error instanceof DownloadApiError)) return 'failed'
if (error.is('OAUTH_NOT_CONFIGURED')) return 'needs_env_key'
// A login already running just needs polling; anything else is a failure.
if (!error.is('LOGIN_IN_PROGRESS')) return 'failed'
}
return pollUntilResolved(provider)
}
async function logout(provider: DownloadProvider) {
await logoutProvider(provider)
await fetchStatus()
}
return {
providers,
isLoading,
statusFor,
isAuthenticated,
fetchStatus,
login,
logout
}
})

View File

@@ -0,0 +1,388 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const flushPromises = () => vi.advanceTimersByTimeAsync(0)
import * as downloadApi from '../api/modelDownloadApi'
import type { DownloadState, DownloadStatus } from '../types'
import {
downloadProgressFraction,
useModelDownloadStore
} from './modelDownloadStore'
type ProgressHandler = (e: CustomEvent<DownloadStatus>) => void
const eventHandler = vi.hoisted(() => {
const state: { current: ProgressHandler | null } = { current: null }
return state
})
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn((_event: string, handler: ProgressHandler) => {
eventHandler.current = handler
})
}
}))
vi.mock('../api/modelDownloadApi', () => ({
enqueueDownload: vi.fn(),
listDownloads: vi.fn(),
pauseDownload: vi.fn(),
resumeDownload: vi.fn(),
cancelDownload: vi.fn(),
setDownloadPriority: vi.fn(),
deleteDownload: vi.fn(),
clearDownloads: vi.fn()
}))
function createStatus(overrides: Partial<DownloadStatus> = {}): DownloadStatus {
return {
download_id: 'd1',
model_id: 'loras/x.safetensors',
url: 'https://huggingface.co/x.safetensors',
status: 'active',
priority: 0,
total_bytes: 1000,
bytes_done: 250,
progress: 0.25,
speed_bps: 100,
eta_seconds: 10,
segments: null,
error: null,
created_at: 1,
updated_at: 2,
...overrides
}
}
function dispatch(status: DownloadStatus) {
if (!eventHandler.current) throw new Error('handler not registered')
eventHandler.current(new CustomEvent('download_progress', { detail: status }))
}
describe('useModelDownloadStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers({ shouldAdvanceTime: false })
vi.resetAllMocks()
eventHandler.current = null
})
afterEach(() => {
vi.useRealTimers()
})
it('upserts rows from download_progress events', () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', bytes_done: 100 }))
dispatch(createStatus({ download_id: 'd1', bytes_done: 900 }))
dispatch(createStatus({ download_id: 'd2', status: 'queued' }))
expect(store.downloadList).toHaveLength(2)
expect(
store.downloadList.find((d) => d.download_id === 'd1')?.bytes_done
).toBe(900)
})
it('splits active and terminal downloads', () => {
const store = useModelDownloadStore()
const states: DownloadState[] = [
'queued',
'active',
'paused',
'verifying',
'completed',
'failed',
'cancelled'
]
states.forEach((status, idx) =>
dispatch(createStatus({ download_id: `d${idx}`, status }))
)
expect(store.activeDownloads.map((d) => d.status)).toEqual([
'queued',
'active',
'paused',
'verifying'
])
expect(store.historyDownloads.map((d) => d.status)).toEqual([
'completed',
'failed',
'cancelled'
])
expect(store.activeDownloadCount).toBe(4)
})
it('inserts an optimistic queued row on enqueue', async () => {
vi.mocked(downloadApi.enqueueDownload).mockResolvedValue({
download_id: 'new-id',
accepted: true
})
const store = useModelDownloadStore()
const result = await store.enqueue({
url: 'https://huggingface.co/x.safetensors',
model_id: 'loras/x.safetensors'
})
expect(result.download_id).toBe('new-id')
const row = store.downloadList.find((d) => d.download_id === 'new-id')
expect(row?.status).toBe('queued')
expect(row?.model_id).toBe('loras/x.safetensors')
})
it('optimistically updates status when pausing', async () => {
vi.mocked(downloadApi.pauseDownload).mockResolvedValue()
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
await store.pause('d1')
expect(store.downloadList.find((d) => d.download_id === 'd1')?.status).toBe(
'paused'
)
expect(downloadApi.pauseDownload).toHaveBeenCalledWith('d1')
})
it('optimistically updates status when resuming', async () => {
vi.mocked(downloadApi.resumeDownload).mockResolvedValue()
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'paused' }))
await store.resume('d1')
expect(store.downloadList.find((d) => d.download_id === 'd1')?.status).toBe(
'queued'
)
expect(downloadApi.resumeDownload).toHaveBeenCalledWith('d1')
})
it('marks a download cancelled after the API call resolves', async () => {
vi.mocked(downloadApi.cancelDownload).mockResolvedValue()
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
await store.cancel('d1')
expect(store.downloadList.find((d) => d.download_id === 'd1')?.status).toBe(
'cancelled'
)
expect(downloadApi.cancelDownload).toHaveBeenCalledWith('d1')
})
it('optimistically updates priority and calls the API', async () => {
vi.mocked(downloadApi.setDownloadPriority).mockResolvedValue()
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'queued', priority: 0 }))
await store.setPriority('d1', 5)
expect(
store.downloadList.find((d) => d.download_id === 'd1')?.priority
).toBe(5)
expect(downloadApi.setDownloadPriority).toHaveBeenCalledWith('d1', 5)
})
it('is a no-op when patching priority for an unknown id', async () => {
vi.mocked(downloadApi.setDownloadPriority).mockResolvedValue()
const store = useModelDownloadStore()
await store.setPriority('missing', 5)
expect(store.downloadList).toHaveLength(0)
expect(downloadApi.setDownloadPriority).toHaveBeenCalledWith('missing', 5)
})
it('finds a download by model id', () => {
const store = useModelDownloadStore()
dispatch(
createStatus({ download_id: 'd1', model_id: 'loras/x.safetensors' })
)
expect(store.findByModelId('loras/x.safetensors')?.download_id).toBe('d1')
expect(store.findByModelId('loras/missing.safetensors')).toBeUndefined()
})
describe('hydrate', () => {
it('replaces the download map with the fetched list', async () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'stale', status: 'active' }))
vi.mocked(downloadApi.listDownloads).mockResolvedValue([
createStatus({ download_id: 'fresh', status: 'active' })
])
await store.hydrate()
expect(store.downloadList.map((d) => d.download_id)).toEqual(['fresh'])
})
it('records a completion when a refreshed row transitions to completed', async () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
vi.mocked(downloadApi.listDownloads).mockResolvedValue([
createStatus({
download_id: 'd1',
model_id: 'loras/x.safetensors',
status: 'completed'
})
])
await store.hydrate()
expect(store.lastCompletedDownload).toMatchObject({
downloadId: 'd1',
modelId: 'loras/x.safetensors',
directory: 'loras'
})
})
it('does not re-record a completion for a row that was already completed', async () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'completed' }))
const firstTimestamp = store.lastCompletedDownload?.timestamp
vi.mocked(downloadApi.listDownloads).mockResolvedValue([
createStatus({ download_id: 'd1', status: 'completed' })
])
await store.hydrate()
expect(store.lastCompletedDownload?.timestamp).toBe(firstTimestamp)
})
})
it('records the last completed download once on the completing transition', () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
expect(store.lastCompletedDownload).toBeNull()
dispatch(
createStatus({
download_id: 'd1',
model_id: 'loras/x.safetensors',
status: 'completed'
})
)
expect(store.lastCompletedDownload).toMatchObject({
downloadId: 'd1',
modelId: 'loras/x.safetensors',
directory: 'loras'
})
const firstTimestamp = store.lastCompletedDownload?.timestamp
dispatch(createStatus({ download_id: 'd1', status: 'completed' }))
expect(store.lastCompletedDownload?.timestamp).toBe(firstTimestamp)
})
it('refetches when a download fails without an error message', async () => {
vi.mocked(downloadApi.listDownloads).mockResolvedValue([
createStatus({ download_id: 'd1', status: 'failed', error: 'gated' })
])
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
dispatch(createStatus({ download_id: 'd1', status: 'failed', error: null }))
await flushPromises()
expect(downloadApi.listDownloads).toHaveBeenCalledOnce()
expect(store.downloadList.find((d) => d.download_id === 'd1')?.error).toBe(
'gated'
)
})
it('does not refetch when the failure event already carries an error', async () => {
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
dispatch(
createStatus({ download_id: 'd1', status: 'failed', error: 'disk full' })
)
await flushPromises()
expect(downloadApi.listDownloads).not.toHaveBeenCalled()
expect(store.downloadList.find((d) => d.download_id === 'd1')?.error).toBe(
'disk full'
)
})
it('deletes a row through the backend so it stays gone', async () => {
vi.mocked(downloadApi.deleteDownload).mockResolvedValue()
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
dispatch(createStatus({ download_id: 'd2', status: 'completed' }))
await store.remove('d2')
expect(downloadApi.deleteDownload).toHaveBeenCalledWith('d2')
expect(store.downloadList.map((d) => d.download_id)).toEqual(['d1'])
})
it('clears every history row in one backend call, leaving active downloads', async () => {
vi.mocked(downloadApi.clearDownloads).mockResolvedValue(2)
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
dispatch(createStatus({ download_id: 'd2', status: 'completed' }))
dispatch(createStatus({ download_id: 'd3', status: 'failed' }))
await store.clearHistory()
expect(downloadApi.clearDownloads).toHaveBeenCalledOnce()
expect(downloadApi.deleteDownload).not.toHaveBeenCalled()
expect(store.downloadList.map((d) => d.download_id)).toEqual(['d1'])
})
it('keeps history rows locally when the bulk clear fails', async () => {
vi.mocked(downloadApi.clearDownloads).mockRejectedValue(new Error('boom'))
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd2', status: 'completed' }))
await expect(store.clearHistory()).rejects.toThrow('boom')
expect(store.downloadList.map((d) => d.download_id)).toEqual(['d2'])
})
it('keeps the row locally when the backend delete fails', async () => {
vi.mocked(downloadApi.deleteDownload).mockRejectedValue(new Error('boom'))
const store = useModelDownloadStore()
dispatch(createStatus({ download_id: 'd2', status: 'completed' }))
await expect(store.remove('d2')).rejects.toThrow('boom')
expect(store.downloadList.map((d) => d.download_id)).toEqual(['d2'])
})
it('polls the list when active downloads go stale', async () => {
vi.mocked(downloadApi.listDownloads).mockResolvedValue([])
useModelDownloadStore()
dispatch(createStatus({ download_id: 'd1', status: 'active' }))
await vi.advanceTimersByTimeAsync(15_000)
expect(downloadApi.listDownloads).toHaveBeenCalled()
})
describe('downloadProgressFraction', () => {
it('uses live progress when present', () => {
expect(downloadProgressFraction(createStatus({ progress: 0.4 }))).toBe(
0.4
)
})
it('derives from bytes when progress is null', () => {
expect(
downloadProgressFraction(
createStatus({ progress: null, bytes_done: 500, total_bytes: 1000 })
)
).toBe(0.5)
})
it('returns null when total is unknown', () => {
expect(
downloadProgressFraction(
createStatus({ progress: null, total_bytes: null })
)
).toBeNull()
})
})
})

View File

@@ -0,0 +1,229 @@
import { useIntervalFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api } from '@/scripts/api'
import {
cancelDownload,
clearDownloads,
deleteDownload,
enqueueDownload,
listDownloads,
pauseDownload,
resumeDownload,
setDownloadPriority
} from '../api/modelDownloadApi'
import type {
DownloadState,
DownloadStatus,
EnqueueRequest,
EnqueueResponse
} from '../types'
import { directoryOf } from '../utils/modelId'
const ACTIVE_STATES: ReadonlySet<DownloadState> = new Set([
'queued',
'active',
'paused',
'verifying'
])
const POLL_INTERVAL_MS = 5_000
const STALE_THRESHOLD_MS = 8_000
/**
* Static completion fraction (0..1) for a download. Live values (`progress`)
* are only populated while a worker is running; for paused/terminal rows we
* derive it from `bytes_done / total_bytes`.
*/
export function downloadProgressFraction(
download: DownloadStatus
): number | null {
if (download.progress != null) return download.progress
if (download.total_bytes && download.total_bytes > 0) {
return download.bytes_done / download.total_bytes
}
return null
}
function optimisticRow(
downloadId: string,
request: EnqueueRequest
): DownloadStatus {
const now = Math.floor(Date.now() / 1000)
return {
download_id: downloadId,
model_id: request.model_id,
url: request.url,
status: 'queued',
priority: request.priority ?? 0,
total_bytes: null,
bytes_done: 0,
progress: null,
speed_bps: null,
eta_seconds: null,
segments: null,
error: null,
created_at: now,
updated_at: now
}
}
export interface CompletedDownload {
downloadId: string
modelId: string
directory: string
timestamp: number
}
export const useModelDownloadStore = defineStore('modelDownload', () => {
const downloads = ref<Map<string, DownloadStatus>>(new Map())
const lastWsUpdate = ref(0)
const lastCompletedDownload = ref<CompletedDownload | null>(null)
const downloadList = computed(() => Array.from(downloads.value.values()))
const activeDownloads = computed(() =>
downloadList.value.filter((d) => ACTIVE_STATES.has(d.status))
)
const historyDownloads = computed(() =>
downloadList.value.filter((d) => !ACTIVE_STATES.has(d.status))
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
const activeDownloadCount = computed(() => activeDownloads.value.length)
function upsert(status: DownloadStatus) {
downloads.value.set(status.download_id, status)
}
function findByModelId(modelId: string): DownloadStatus | undefined {
return downloadList.value.find((d) => d.model_id === modelId)
}
function handleProgress(e: CustomEvent<DownloadStatus>) {
lastWsUpdate.value = Date.now()
const previous = downloads.value.get(e.detail.download_id)
upsert(e.detail)
if (e.detail.status === 'completed' && previous?.status !== 'completed') {
lastCompletedDownload.value = {
downloadId: e.detail.download_id,
modelId: e.detail.model_id,
directory: directoryOf(e.detail.model_id),
timestamp: Date.now()
}
}
// The live failure event carries the terminal status but not the error
// text, and polling stops once nothing is active. Refetch so the failure
// reason surfaces without the user reopening the panel.
if (
e.detail.status === 'failed' &&
previous?.status !== 'failed' &&
!e.detail.error
) {
void hydrate().catch(() => {})
}
}
async function hydrate() {
const previous = downloads.value
const list = await listDownloads()
downloads.value = new Map(list.map((d) => [d.download_id, d]))
for (const download of list) {
const prior = previous.get(download.download_id)
if (
download.status === 'completed' &&
prior &&
prior.status !== 'completed'
) {
lastCompletedDownload.value = {
downloadId: download.download_id,
modelId: download.model_id,
directory: directoryOf(download.model_id),
timestamp: Date.now()
}
}
}
}
async function enqueue(request: EnqueueRequest): Promise<EnqueueResponse> {
const response = await enqueueDownload(request)
upsert(optimisticRow(response.download_id, request))
return response
}
function patchStatus(id: string, status: DownloadState) {
const existing = downloads.value.get(id)
if (existing) {
downloads.value.set(id, { ...existing, status })
}
}
async function pause(id: string) {
patchStatus(id, 'paused')
await pauseDownload(id)
}
async function resume(id: string) {
patchStatus(id, 'queued')
await resumeDownload(id)
}
async function cancel(id: string) {
await cancelDownload(id)
patchStatus(id, 'cancelled')
}
async function setPriority(id: string, priority: number) {
const existing = downloads.value.get(id)
if (existing) {
downloads.value.set(id, { ...existing, priority })
}
await setDownloadPriority(id, priority)
}
async function remove(id: string) {
await deleteDownload(id)
downloads.value.delete(id)
}
async function clearHistory() {
const clearedIds = historyDownloads.value.map((d) => d.download_id)
await clearDownloads()
for (const id of clearedIds) {
downloads.value.delete(id)
}
}
async function pollIfStale() {
if (!hasActiveDownloads.value) return
if (Date.now() - lastWsUpdate.value < STALE_THRESHOLD_MS) return
try {
await hydrate()
} catch {
// Server unreachable; retry on next interval
}
}
useIntervalFn(() => void pollIfStale(), POLL_INTERVAL_MS)
api.addEventListener('download_progress', handleProgress)
return {
downloadList,
activeDownloads,
historyDownloads,
hasActiveDownloads,
activeDownloadCount,
lastCompletedDownload,
upsert,
findByModelId,
hydrate,
enqueue,
pause,
resume,
cancel,
setPriority,
remove,
clearHistory
}
})

View File

@@ -0,0 +1,131 @@
import type { DownloadState, DownloadStatus } from '@/schemas/apiSchema'
export type { DownloadState, DownloadStatus }
/**
* Known model file extensions accepted by the backend without
* `allow_any_extension`. Mirrors the server's extension allowlist and is
* used for instant client-side validation only — the server stays the source
* of truth.
*/
export const MODEL_EXTENSIONS = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt',
'.gguf',
'.bin'
] as const
/**
* Hosts the backend allows out of the box. Admins can extend this
* server-side, so this list is only for optimistic client-side hints; rely on
* the server's `URL_NOT_ALLOWED` / `url_allowed` as the source of truth.
*/
export const DEFAULT_ALLOWED_HOSTS = [
'huggingface.co',
'civitai.com',
'localhost',
'127.0.0.1'
] as const
export interface EnqueueRequest {
url: string
model_id: string
priority?: number
expected_sha256?: string | null
allow_any_extension?: boolean
}
export interface EnqueueResponse {
download_id: string
accepted: boolean
}
/**
* Providers whose gated/private downloads can require server-side auth. The
* backend resolves a token per request (env key first, then OAuth), so the
* frontend only ever reads status — never a token value.
*/
export const DOWNLOAD_PROVIDERS = ['huggingface', 'civitai'] as const
export type DownloadProvider = (typeof DOWNLOAD_PROVIDERS)[number]
/**
* Per-provider auth status from `GET /download/auth`. A provider is
* authenticated (the frontend needs to do nothing) when
* `env_key_present || logged_in`.
*/
export interface ProviderAuthStatus {
provider: DownloadProvider
/** An API-key env var is set on the server for this provider. */
env_key_present: boolean
/** A stored OAuth token exists for this provider. */
logged_in: boolean
/** An OAuth login is awaiting its browser callback. */
login_in_progress: boolean
}
export interface LoginStartResponse {
authorize_url: string
}
interface AvailabilityBase {
url_allowed: boolean
}
export type AvailabilityEntry =
| (AvailabilityBase & { state: 'available' })
| (AvailabilityBase & { state: 'missing' })
| (AvailabilityBase & {
state: 'downloading'
download_id: string
progress: number | null
bytes_done: number
total_bytes: number | null
speed_bps: number | null
})
export interface AvailabilityResponse {
models: Record<string, AvailabilityEntry>
}
const DOWNLOAD_ERROR_CODES = [
'INVALID_JSON',
'INVALID_BODY',
'URL_NOT_ALLOWED',
'INVALID_MODEL_ID',
'ALREADY_AVAILABLE',
'ALREADY_DOWNLOADING',
'DOWNLOAD_ACTIVE',
'NOT_FOUND',
// Auth failures surfaced by enqueue when the URL needs a network resolve.
'GATED_REPO',
'CREDENTIALS_REQUIRED',
// Provider OAuth login (`/download/auth/{provider}/login`).
'UNKNOWN_PROVIDER',
'OAUTH_NOT_CONFIGURED',
'LOGIN_IN_PROGRESS'
] as const
export type DownloadErrorCode = (typeof DOWNLOAD_ERROR_CODES)[number]
/**
* Error envelope returned by every download-manager endpoint on failure.
* `code` is the stable machine-readable discriminator; `message` is
* user-facing; `details` is an open object (do not assume a shape).
*/
export class DownloadApiError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly status: number,
public readonly details?: Record<string, unknown>
) {
super(message)
this.name = 'DownloadApiError'
}
is(code: DownloadErrorCode): boolean {
return this.code === code
}
}

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isLocalDeployment } from './deployment'
const distribution = vi.hoisted(() => ({ isDesktop: false }))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return distribution.isDesktop
}
}))
function setHostname(hostname: string) {
Object.defineProperty(window, 'location', {
configurable: true,
value: { hostname }
})
}
describe('isLocalDeployment', () => {
afterEach(() => {
distribution.isDesktop = false
})
it('is true for the desktop app regardless of hostname', () => {
distribution.isDesktop = true
setHostname('some-remote-host.example.com')
expect(isLocalDeployment()).toBe(true)
})
it.for(['localhost', '127.0.0.1', '::1', '[::1]'])(
'is true for the loopback host %s',
(hostname) => {
setHostname(hostname)
expect(isLocalDeployment()).toBe(true)
}
)
it('is false for a remote hostname', () => {
setHostname('192.168.1.50')
expect(isLocalDeployment()).toBe(false)
setHostname('models.example.com')
expect(isLocalDeployment()).toBe(false)
})
})

View File

@@ -0,0 +1,14 @@
import { isDesktop } from '@/platform/distribution/types'
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
/**
* Whether the browser can reach the server's `127.0.0.1` loopback, which is
* required for the model-download OAuth redirect to complete. True for the
* Desktop app and for loopback hostnames; a remote server is treated as
* non-local and must use the env-var API-key path instead.
*/
export function isLocalDeployment(): boolean {
if (isDesktop) return true
return LOOPBACK_HOSTS.has(window.location.hostname)
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
buildModelId,
filenameFromUrl,
hasModelExtension,
hostFromUrl,
isLikelyAllowedHost,
isValidPathSegment
} from './modelId'
describe('modelId utils', () => {
describe('isValidPathSegment', () => {
it('accepts valid filenames', () => {
expect(isValidPathSegment('my_lora.safetensors')).toBe(true)
expect(isValidPathSegment('sdxl-base.ckpt')).toBe(true)
})
it('rejects traversal, leading dots, and slashes', () => {
expect(isValidPathSegment('..')).toBe(false)
expect(isValidPathSegment('.hidden')).toBe(false)
expect(isValidPathSegment('nested/path')).toBe(false)
expect(isValidPathSegment('')).toBe(false)
})
})
describe('hasModelExtension', () => {
it('matches known extensions case-insensitively', () => {
expect(hasModelExtension('model.SAFETENSORS')).toBe(true)
expect(hasModelExtension('weights.gguf')).toBe(true)
})
it('rejects unknown extensions', () => {
expect(hasModelExtension('readme.txt')).toBe(false)
expect(hasModelExtension('archive.zip')).toBe(false)
})
})
describe('buildModelId', () => {
it('joins directory and filename with a single slash', () => {
expect(buildModelId('loras', 'x.safetensors')).toBe('loras/x.safetensors')
})
it('collapses redundant slashes at the join boundary', () => {
expect(buildModelId('loras/', 'x.safetensors')).toBe(
'loras/x.safetensors'
)
expect(buildModelId('loras', '/x.safetensors')).toBe(
'loras/x.safetensors'
)
expect(buildModelId('loras//', '//x.safetensors')).toBe(
'loras/x.safetensors'
)
})
})
describe('hostFromUrl', () => {
it('extracts the lowercased host', () => {
expect(hostFromUrl('https://HuggingFace.co/a/b')).toBe('huggingface.co')
})
it('returns null for invalid URLs', () => {
expect(hostFromUrl('not a url')).toBeNull()
})
})
describe('isLikelyAllowedHost', () => {
it('allows built-in hosts and their subdomains', () => {
expect(isLikelyAllowedHost('https://huggingface.co/x')).toBe(true)
expect(isLikelyAllowedHost('https://cdn.huggingface.co/x')).toBe(true)
expect(isLikelyAllowedHost('https://civitai.com/api/download/1')).toBe(
true
)
})
it('flags unknown hosts', () => {
expect(isLikelyAllowedHost('https://example.com/x')).toBe(false)
expect(isLikelyAllowedHost('garbage')).toBe(false)
})
it('does not treat a fake subdomain of an allowed IP literal as allowed', () => {
expect(isLikelyAllowedHost('https://127.0.0.1/x')).toBe(true)
expect(isLikelyAllowedHost('https://evil.127.0.0.1/x')).toBe(false)
})
})
describe('filenameFromUrl', () => {
it('returns the decoded trailing path segment', () => {
expect(filenameFromUrl('https://h.co/a/my%20model.safetensors')).toBe(
'my model.safetensors'
)
})
it('returns empty string when no segment is present', () => {
expect(filenameFromUrl('https://h.co/')).toBe('')
expect(filenameFromUrl('bad')).toBe('')
})
})
})

View File

@@ -0,0 +1,79 @@
import { DEFAULT_ALLOWED_HOSTS, MODEL_EXTENSIONS } from '../types'
const SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
export function isValidPathSegment(segment: string): boolean {
return SEGMENT_PATTERN.test(segment)
}
export function hasModelExtension(filename: string): boolean {
const lower = filename.toLowerCase()
return MODEL_EXTENSIONS.some((ext) => lower.endsWith(ext))
}
export function buildModelId(directory: string, filename: string): string {
return `${directory.replace(/\/+$/, '')}/${filename.replace(/^\/+/, '')}`
}
/**
* Directory portion of a `directory/filename` model id, or an empty string when
* the id has no directory prefix.
*/
export function directoryOf(modelId: string): string {
const slash = modelId.indexOf('/')
return slash === -1 ? '' : modelId.slice(0, slash)
}
/**
* Filename portion of a `directory/filename` model id, falling back to the full
* id when there is no directory prefix.
*/
export function filenameOf(modelId: string): string {
const slash = modelId.indexOf('/')
return slash === -1 ? modelId : modelId.slice(slash + 1)
}
/**
* Lowercased host of a URL, or `null` when the URL is unparseable.
*/
export function hostFromUrl(url: string): string | null {
try {
return new URL(url).hostname.toLowerCase()
} catch {
return null
}
}
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/
function isIpLiteral(host: string): boolean {
return IPV4_PATTERN.test(host) || host.includes(':')
}
/**
* Optimistic client-side allowlist hint. The server can extend the
* allowlist, so a `false` here is advisory — defer to `URL_NOT_ALLOWED`.
*/
export function isLikelyAllowedHost(url: string): boolean {
const host = hostFromUrl(url)
if (!host) return false
return DEFAULT_ALLOWED_HOSTS.some(
(allowed) =>
host === allowed ||
(!isIpLiteral(allowed) && host.endsWith(`.${allowed}`))
)
}
/**
* Best-effort filename guess from a URL path, for prefilling the model_id
* filename field. May be empty when the URL has no usable trailing segment.
*/
export function filenameFromUrl(url: string): string {
try {
const { pathname } = new URL(url)
const last = pathname.split('/').filter(Boolean).pop() ?? ''
return decodeURIComponent(last)
} catch {
return ''
}
}

View File

@@ -135,22 +135,25 @@ describe('FormDropdownMenu', () => {
})
/** Regression: PrimeVue Popover teleports the menu to document.body, so
* trackpad pinch-zoom must be guarded on the menu itself — `LGraphNode`
* never sees these events. macOS pinch synthesizes `ctrlKey`; explicit
* `⌘ + wheel` (and Windows/Linux equivalents) come through as `metaKey`. */
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
* itself rather than relying on the LGraphNode wheel handler. */
it.for([
{ name: 'ctrlKey', modifier: 'ctrlKey' as const },
{ name: 'metaKey', modifier: 'metaKey' as const }
])('suppresses browser default for pinch-zoom ($name)', ({ modifier }) => {
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
])('suppresses browser default for $name', ({ overrides }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
Object.defineProperty(event, modifier, { value: true })
Object.defineProperty(event, 'deltaY', { value: -10 })
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true
})
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
@@ -208,33 +211,19 @@ describe('FormDropdownMenu', () => {
expect(emitted('show-picker')).toHaveLength(1)
})
/** Regression: slow trackpad vertical scrolls emit small-delta frames with
* stray horizontal jitter (`|deltaX| > |deltaY|`). preventDefault-ing those
* cancelled the native scroll and starved the VirtualGrid's `useScroll`,
* leaving rows blank. Horizontal-swipe-to-navigate is blocked at the page
* boundary by `overscroll-behavior: none` on `html, body`, so the local
* handler must NOT preventDefault these events. The `pure vertical` case
* also covers plain native scrolling of the dropdown's own content. */
it.for([
{ name: 'pure vertical', overrides: { deltaY: 30 } },
{
name: 'slow scroll with horizontal jitter',
overrides: { deltaX: 1.5, deltaY: 1.2 }
},
{
name: 'pronounced horizontal swipe',
overrides: { deltaX: 30, deltaY: 5 }
}
])('does not suppress $name wheel', ({ overrides }) => {
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
const event = new WheelEvent('wheel', {
deltaY: 30,
bubbles: true,
cancelable: true
})
root.dispatchEvent(event)

Some files were not shown because too many files have changed in this diff Show More