mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
19 Commits
fix/ppform
...
model_down
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d1403ac7 | ||
|
|
be7aca3d5a | ||
|
|
8fb6f40d16 | ||
|
|
645b2b53c2 | ||
|
|
5c364b7e0b | ||
|
|
b21183ce09 | ||
|
|
78109c66c2 | ||
|
|
b78e5a8c70 | ||
|
|
3ae3fbe83d | ||
|
|
9c62159d52 | ||
|
|
dffc81524c | ||
|
|
c8873a1cb2 | ||
|
|
8c8a5e9212 | ||
|
|
bd303074be | ||
|
|
d57a243e97 | ||
|
|
3ff5312fc5 | ||
|
|
81012d28a4 | ||
|
|
897caaf90e | ||
|
|
0f6e7f8770 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -276,7 +276,6 @@ export const TestIds = {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3d: {
|
||||
categoryMenu: 'load3d-category-menu',
|
||||
recordingDuration: 'load3d-recording-duration'
|
||||
},
|
||||
load3dViewer: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
250
browser_tests/tests/sidebar/modelManagerSidebarTab.spec.ts
Normal file
250
browser_tests/tests/sidebar/modelManagerSidebarTab.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
205
src/components/load3d/controls/RecordingControls.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
126
src/components/load3d/controls/RecordingControls.vue
Normal file
126
src/components/load3d/controls/RecordingControls.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
114
src/components/ui/dialog/useModalPointerLock.test.ts
Normal file
114
src/components/ui/dialog/useModalPointerLock.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
54
src/components/ui/dialog/useModalPointerLock.ts
Normal file
54
src/components/ui/dialog/useModalPointerLock.ts
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -108,7 +108,6 @@ describe('MeshModelAdapter', () => {
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
279
src/platform/modelManager/api/modelDownloadApi.test.ts
Normal file
279
src/platform/modelManager/api/modelDownloadApi.test.ts
Normal 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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
127
src/platform/modelManager/api/modelDownloadApi.ts
Normal file
127
src/platform/modelManager/api/modelDownloadApi.ts
Normal 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)
|
||||
}
|
||||
253
src/platform/modelManager/components/AddModelByUrlDialog.test.ts
Normal file
253
src/platform/modelManager/components/AddModelByUrlDialog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
248
src/platform/modelManager/components/AddModelByUrlDialog.vue
Normal file
248
src/platform/modelManager/components/AddModelByUrlDialog.vue
Normal 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>
|
||||
54
src/platform/modelManager/components/DownloadAuthDialog.vue
Normal file
54
src/platform/modelManager/components/DownloadAuthDialog.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
185
src/platform/modelManager/components/DownloadAuthProviderRow.vue
Normal file
185
src/platform/modelManager/components/DownloadAuthProviderRow.vue
Normal 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>
|
||||
359
src/platform/modelManager/components/ModelDownloadRow.test.ts
Normal file
359
src/platform/modelManager/components/ModelDownloadRow.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
265
src/platform/modelManager/components/ModelDownloadRow.vue
Normal file
265
src/platform/modelManager/components/ModelDownloadRow.vue
Normal 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>
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
101
src/platform/modelManager/components/ModelManagerSidebarTab.vue
Normal file
101
src/platform/modelManager/components/ModelManagerSidebarTab.vue
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/platform/modelManager/downloadAuthProviders.test.ts
Normal file
68
src/platform/modelManager/downloadAuthProviders.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
78
src/platform/modelManager/downloadAuthProviders.ts
Normal file
78
src/platform/modelManager/downloadAuthProviders.ts
Normal 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))
|
||||
}
|
||||
135
src/platform/modelManager/stores/downloadAuthStore.test.ts
Normal file
135
src/platform/modelManager/stores/downloadAuthStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
98
src/platform/modelManager/stores/downloadAuthStore.ts
Normal file
98
src/platform/modelManager/stores/downloadAuthStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
388
src/platform/modelManager/stores/modelDownloadStore.test.ts
Normal file
388
src/platform/modelManager/stores/modelDownloadStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/platform/modelManager/stores/modelDownloadStore.ts
Normal file
229
src/platform/modelManager/stores/modelDownloadStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
131
src/platform/modelManager/types.ts
Normal file
131
src/platform/modelManager/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
47
src/platform/modelManager/utils/deployment.test.ts
Normal file
47
src/platform/modelManager/utils/deployment.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
14
src/platform/modelManager/utils/deployment.ts
Normal file
14
src/platform/modelManager/utils/deployment.ts
Normal 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)
|
||||
}
|
||||
99
src/platform/modelManager/utils/modelId.test.ts
Normal file
99
src/platform/modelManager/utils/modelId.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
79
src/platform/modelManager/utils/modelId.ts
Normal file
79
src/platform/modelManager/utils/modelId.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user