mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
58 Commits
v1.44.10
...
sno-fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba055befe | ||
|
|
4c8e5ad797 | ||
|
|
d3bd6f9f12 | ||
|
|
a785f72aa0 | ||
|
|
6130880992 | ||
|
|
fb46996002 | ||
|
|
bf11a90cd8 | ||
|
|
0c4861162d | ||
|
|
17f5bde180 | ||
|
|
d840020427 | ||
|
|
1a993fc1dc | ||
|
|
321c32463e | ||
|
|
0a441ab896 | ||
|
|
cf01213235 | ||
|
|
61bcf0a8bc | ||
|
|
0b90645d87 | ||
|
|
8f60294f63 | ||
|
|
da8be4dc6c | ||
|
|
3fa9c4522a | ||
|
|
2b675d6b5c | ||
|
|
66072fc4a6 | ||
|
|
5f612e19b2 | ||
|
|
eb1fe9d88a | ||
|
|
51e77c65ad | ||
|
|
324d20477e | ||
|
|
6fb9915b45 | ||
|
|
fed451edac | ||
|
|
3ee55dfa1e | ||
|
|
93073cc242 | ||
|
|
5ef07f09fa | ||
|
|
2d5d77f7db | ||
|
|
ef4d8622fa | ||
|
|
dc2d8375fd | ||
|
|
bd0a10d7f0 | ||
|
|
5b75bb5bbf | ||
|
|
8bdc850830 | ||
|
|
95d0f22906 | ||
|
|
a53ea4dae2 | ||
|
|
7ba5fcdf1a | ||
|
|
c176723bbc | ||
|
|
559001341a | ||
|
|
b9c677f54c | ||
|
|
6304a60656 | ||
|
|
0b0a1076f4 | ||
|
|
d1968f2033 | ||
|
|
f129dc2aeb | ||
|
|
1a4b766fb7 | ||
|
|
3e8022c0d1 | ||
|
|
cb696d8426 | ||
|
|
3720ba3829 | ||
|
|
cdc834705a | ||
|
|
177224452e | ||
|
|
6bf75b4cf0 | ||
|
|
7a13340989 | ||
|
|
1b07e82ff7 | ||
|
|
9f4c54eb24 | ||
|
|
6f6fc88b0f | ||
|
|
492bec28c8 |
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
|
||||
name: 'CI: Deploy Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"starting"
|
||||
|
||||
# Build frontend for all PRs and pushes
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
|
||||
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
if: always()
|
||||
run: |
|
||||
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get workflow URL
|
||||
id: workflow-url
|
||||
if: always()
|
||||
run: |
|
||||
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload build artifact
|
||||
if: success() && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [comment-on-pr-start, build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download build artifact
|
||||
if: needs.build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy preview and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
|
||||
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.ref_name }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
pnpm dlx wrangler@^4.0.0 pages deploy dist \
|
||||
--project-name=comfy-ui \
|
||||
--branch=main
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -7,6 +7,12 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
|
||||
'/customers/series-entertainment/'
|
||||
},
|
||||
build: {
|
||||
assets: '_website'
|
||||
},
|
||||
|
||||
@@ -119,7 +119,15 @@
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "v1-5-pruned-emaonly-fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -261,6 +261,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
public readonly filterButton: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
@@ -299,6 +300,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.filterButton = page.getByRole('button', { name: 'Filter by' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
@@ -334,6 +336,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
filterCheckbox(label: string) {
|
||||
return this.page.getByRole('checkbox', { name: label })
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
@@ -383,6 +389,16 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async openFilterMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.filterButton.click()
|
||||
// Wait for popover content with checkboxes to render
|
||||
await this.filterCheckbox('Image').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
@@ -772,3 +772,119 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 12. Media type filter (cloud-only)
|
||||
// ==========================================================================
|
||||
|
||||
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-image',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'photo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-video',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2010,
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'video'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-audio',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3010,
|
||||
preview_output: {
|
||||
filename: 'track.mp3',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'audio'
|
||||
},
|
||||
outputs_count: 1
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Filter menu shows media type options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
await expect(tab.filterCheckbox('Image')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('Video')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('Audio')).toBeVisible()
|
||||
await expect(tab.filterCheckbox('3D')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = tab.assetCards
|
||||
await expect(
|
||||
initialCount,
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
|
||||
|
||||
## 背景
|
||||
|
||||
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URL(prod / staging / カスタム)を選択する。
|
||||
フロントエンドは `__USE_PROD_CONFIG__` ビルド時定数で同じ値を選ぶ。
|
||||
両者が食い違うと、フロントエンドが発行した Firebase トークン(または API キー)が
|
||||
バックエンド経由で別の環境に投げられ、認証や課金が落ちる。
|
||||
|
||||
現状の検出方法(案 A、`src/views/ConnectionPanelView.vue`)は
|
||||
`/api/system_stats` の `system.argv`(CLI 全引数)から `--comfy-api-base` を grep するもの。
|
||||
動くが脆い:
|
||||
|
||||
- 引数の書式(`--flag VALUE` vs `--flag=VALUE`)に依存する
|
||||
- バックエンド側の CLI シグネチャが変わると壊れる
|
||||
- 「公開 API ではない情報」を検出ロジックに使っている
|
||||
|
||||
## 提案
|
||||
|
||||
ComfyUI 本体の `/features` エンドポイントに `comfy_api_base` を追加する。
|
||||
`/features` はすでに「構造化された機能/設定の公開 API」という位置付けがあり、ここに含めるのが自然。
|
||||
|
||||
### バックエンドの実装スケッチ
|
||||
|
||||
```python
|
||||
# tmp/ComfyUI/comfy_api/feature_flags.py:65 付近
|
||||
def get_server_features() -> dict[str, Any]:
|
||||
from comfy.cli_args import args
|
||||
return {
|
||||
...,
|
||||
"comfy_api_base": args.comfy_api_base,
|
||||
}
|
||||
```
|
||||
|
||||
### フロントエンドの変更
|
||||
|
||||
```ts
|
||||
// 例: src/platform/connectionPanel/ あたりに移設
|
||||
const features = await fetch(`${base}/api/features`).then((r) => r.json())
|
||||
const backendCloudBase =
|
||||
features.comfy_api_base ?? parseBackendCloudBase(stats.system?.argv)
|
||||
```
|
||||
|
||||
`features.comfy_api_base` を優先し、未定義の場合のみ `argv` フォールバックを使う。
|
||||
|
||||
## メリット
|
||||
|
||||
- 構造化された公開 API になり、CLI 変更の影響を受けない
|
||||
- 拡張機能 / カスタムノードからも安定して参照できる
|
||||
- 既存の `/features` パターン(ファースト クラスのバックエンド能力公開)に合致
|
||||
- フロントエンドの検出コードが自明になる
|
||||
|
||||
## デメリット
|
||||
|
||||
- `Comfy-Org/ComfyUI` 本体への PR とリリースが必要
|
||||
- リリース前は案 A をフォールバックとして残す必要がある
|
||||
- `comfy_api_base` を「公開してよい情報」と扱う合意が必要
|
||||
(カスタム URL を使うユーザーには内部 URL が露出することになる)
|
||||
|
||||
## ロードマップ
|
||||
|
||||
1. **案 A をフロントエンドに実装(このコミット)**
|
||||
- `ConnectionPanelView.vue` で `/system_stats` の `argv` を解析
|
||||
- 不一致を検出した場合は黄色の警告を表示
|
||||
2. `Comfy-Org/ComfyUI` に `/features` 拡張 PR を提出
|
||||
- `comfy_api/feature_flags.py:65` に `comfy_api_base` を追加
|
||||
3. 本体リリース後、フロントエンドを `features.comfy_api_base` 優先に切替
|
||||
- `argv` フォールバックは互換性のために残す
|
||||
4. 数バージョン後、`argv` フォールバックを削除
|
||||
|
||||
## 関連ファイル
|
||||
|
||||
- ComfyUI 本体: `comfy/cli_args.py:229` — `--comfy-api-base` 引数定義(デフォルト `https://api.comfy.org`)
|
||||
- ComfyUI 本体: `comfy_api/feature_flags.py:65` — `get_server_features()` の現状
|
||||
- ComfyUI 本体: `server.py:646-685` — `/system_stats` ハンドラ(`argv` を返している)
|
||||
- フロントエンド: `src/config/comfyApi.ts:21-31` — `getComfyApiBaseUrl()`(フロント側のビルド時定数)
|
||||
- フロントエンド: `src/views/ConnectionPanelView.vue` — 案 A 実装場所
|
||||
- フロントエンド: `src/platform/remoteConfig/refreshRemoteConfig.ts` — `/features` 既存利用
|
||||
@@ -27,7 +27,12 @@ const commonGlobals = {
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
__IS_NIGHTLY__: 'readonly',
|
||||
__CI_BRANCH__: 'readonly',
|
||||
__CI_PR_NUMBER__: 'readonly',
|
||||
__CI_PR_AUTHOR__: 'readonly',
|
||||
__CI_RUN_ID__: 'readonly',
|
||||
__CI_JOB_ID__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -2,6 +2,11 @@ declare const __COMFYUI_FRONTEND_VERSION__: string
|
||||
declare const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __CI_BRANCH__: string
|
||||
declare const __CI_PR_NUMBER__: string
|
||||
declare const __CI_PR_AUTHOR__: string
|
||||
declare const __CI_RUN_ID__: string
|
||||
declare const __CI_JOB_ID__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Deploy frontend preview to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
case "$1" in
|
||||
''|*[!0-9]*)
|
||||
echo "Error: PR_NUMBER must be numeric" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
PR_NUMBER="$1"
|
||||
|
||||
# Validate status parameter
|
||||
STATUS="${2:-completed}"
|
||||
case "$STATUS" in
|
||||
starting|completed) ;;
|
||||
*)
|
||||
echo "Error: STATUS must be 'starting' or 'completed'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
|
||||
# Cloudflare variables only required for deployment
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
|
||||
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
COMMENT_MARKER="<!-- COMFYUI_PREVIEW_DEPLOY -->"
|
||||
|
||||
# Resolve wrangler invocation: prefer a locally-available binary, otherwise
|
||||
# run via pnpm dlx to honour the repo's package-manager policy.
|
||||
if command -v wrangler > /dev/null 2>&1; then
|
||||
WRANGLER="wrangler"
|
||||
else
|
||||
WRANGLER="pnpm dlx wrangler@^4.0.0"
|
||||
fi
|
||||
|
||||
# Deploy frontend preview, WARN: ensure inputs are sanitized before calling this function
|
||||
deploy_preview() {
|
||||
dir="$1"
|
||||
branch="$2"
|
||||
|
||||
[ ! -d "$dir" ] && echo "failed" && return
|
||||
|
||||
project="comfy-ui"
|
||||
|
||||
echo "Deploying frontend preview to project $project on branch $branch..." >&2
|
||||
|
||||
# Try deployment up to 3 times
|
||||
i=1
|
||||
while [ $i -le 3 ]; do
|
||||
echo "Deployment attempt $i of 3..." >&2
|
||||
# Branch is already sanitized, use it directly
|
||||
if output=$($WRANGLER pages deploy "$dir" \
|
||||
--project-name="$project" \
|
||||
--branch="$branch" 2>&1); then
|
||||
|
||||
# Prefer the branch alias URL over the deployment hash URL so the
|
||||
# link in the PR comment stays stable across redeploys.
|
||||
branch_url="https://${branch}.${project}.pages.dev"
|
||||
if echo "$output" | grep -qF "$branch_url"; then
|
||||
result="$branch_url"
|
||||
else
|
||||
# Fall back to first pages.dev URL in wrangler output
|
||||
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
|
||||
result="${url:-$branch_url}"
|
||||
fi
|
||||
echo "Success! URL: $result" >&2
|
||||
echo "$result" # Only this goes to stdout for capture
|
||||
return
|
||||
else
|
||||
echo "Deployment failed on attempt $i: $output" >&2
|
||||
fi
|
||||
[ $i -lt 3 ] && sleep 10
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "failed"
|
||||
}
|
||||
|
||||
# Post or update GitHub comment
|
||||
post_comment() {
|
||||
body="$1"
|
||||
temp_file=$(mktemp)
|
||||
echo "$body" > "$temp_file"
|
||||
|
||||
if command -v gh > /dev/null 2>&1; then
|
||||
# Find existing comment ID
|
||||
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
|
||||
|
||||
if [ -n "$existing" ]; then
|
||||
# Update specific comment by ID
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
|
||||
--field body="$(cat "$temp_file")"
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
|
||||
fi
|
||||
else
|
||||
echo "GitHub CLI not available, outputting comment:"
|
||||
cat "$temp_file"
|
||||
fi
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🌐 Frontend Preview: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
# Deploy and post completion comment
|
||||
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
|
||||
# Falls back to pr-$PR_NUMBER if BRANCH_NAME is unset
|
||||
if [ -n "$BRANCH_NAME" ]; then
|
||||
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
|
||||
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
else
|
||||
cloudflare_branch="pr-$PR_NUMBER"
|
||||
fi
|
||||
|
||||
echo "Looking for frontend build in: $(pwd)/dist"
|
||||
|
||||
# Deploy preview if build exists
|
||||
deployment_url="Not deployed"
|
||||
if [ -d "dist" ]; then
|
||||
echo "Found frontend build, deploying..."
|
||||
url=$(deploy_preview "dist" "$cloudflare_branch")
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
deployment_url="[🌐 Open Preview]($url)"
|
||||
else
|
||||
deployment_url="Deployment failed"
|
||||
fi
|
||||
else
|
||||
echo "Frontend build not found at dist"
|
||||
fi
|
||||
|
||||
# Get workflow conclusion from environment or default to success
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate compact header based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Built"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Skipped"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Cancelled"
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Failed"
|
||||
fi
|
||||
|
||||
# Build compact header with optional preview link
|
||||
header="## 🌐 Frontend Preview: $status_icon $status_text"
|
||||
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
header="$header — $deployment_url"
|
||||
fi
|
||||
|
||||
# Build details section
|
||||
details="<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
**Links**
|
||||
- [📊 View Workflow Run]($WORKFLOW_URL)"
|
||||
|
||||
if [ "$deployment_url" != "Not deployed" ]; then
|
||||
if [ "$deployment_url" = "Deployment failed" ]; then
|
||||
details="$details
|
||||
- ❌ Preview deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⚠️ Build failed — $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⏭️ Preview deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
details="$details
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
33
src/components/connection/CopyCodeBlock.vue
Normal file
33
src/components/connection/CopyCodeBlock.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<code
|
||||
class="block rounded-md bg-neutral-800 p-3 pr-10 text-xs whitespace-pre-wrap text-neutral-200 select-all"
|
||||
>{{ text }}</code
|
||||
>
|
||||
<button
|
||||
:title="copied ? t('clipboard.successMessage') : t('g.copyToClipboard')"
|
||||
:aria-label="
|
||||
copied ? t('clipboard.successMessage') : t('g.copyToClipboard')
|
||||
"
|
||||
class="absolute top-2 right-2 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-100"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
copied ? 'icon-[lucide--check] text-green-400' : 'icon-[lucide--copy]'
|
||||
"
|
||||
class="block size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
</script>
|
||||
354
src/components/maskeditor/MaskEditorContent.test.ts
Normal file
354
src/components/maskeditor/MaskEditorContent.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { reactive } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
|
||||
const mockKeyboard = vi.hoisted(() => ({
|
||||
addListeners: vi.fn(),
|
||||
removeListeners: vi.fn()
|
||||
}))
|
||||
|
||||
const mockPanZoom = vi.hoisted(() => ({
|
||||
initializeCanvasPanZoom: vi.fn().mockResolvedValue(undefined),
|
||||
invalidatePanZoom: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockBrushDrawing = vi.hoisted(() => ({
|
||||
initGPUResources: vi.fn().mockResolvedValue(undefined),
|
||||
initPreviewCanvas: vi.fn(),
|
||||
saveBrushSettings: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToolManager = vi.hoisted(() => ({
|
||||
brushDrawing: mockBrushDrawing
|
||||
}))
|
||||
|
||||
const mockImageLoader = vi.hoisted(() => ({
|
||||
loadImages: vi.fn().mockResolvedValue({ width: 100, height: 100 })
|
||||
}))
|
||||
|
||||
const mockMaskEditorLoader = vi.hoisted(() => ({
|
||||
loadFromNode: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockCanvasHistory = vi.hoisted(() => ({
|
||||
saveInitialState: vi.fn(),
|
||||
clearStates: vi.fn()
|
||||
}))
|
||||
|
||||
const initialMockStore = () =>
|
||||
reactive({
|
||||
activeLayer: 'mask' as 'mask' | 'rgb',
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
canvasContainer: null as HTMLElement | null,
|
||||
canvasBackground: null as HTMLElement | null,
|
||||
canvasHistory: mockCanvasHistory,
|
||||
resetState: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMockStore>
|
||||
|
||||
const mockDataStore = vi.hoisted(() => ({
|
||||
reset: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useKeyboard', () => ({
|
||||
useKeyboard: () => mockKeyboard
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/usePanAndZoom', () => ({
|
||||
usePanAndZoom: () => mockPanZoom
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useToolManager', () => ({
|
||||
useToolManager: () => mockToolManager
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useImageLoader', () => ({
|
||||
useImageLoader: () => mockImageLoader
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditorLoader', () => ({
|
||||
useMaskEditorLoader: () => mockMaskEditorLoader
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: () => mockDataStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
default: {
|
||||
name: 'LoadingOverlayStub',
|
||||
props: ['loading', 'size'],
|
||||
template: `<div data-testid="loading-overlay" :data-loading="loading" />`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'ToolPanelStub',
|
||||
props: ['toolManager'],
|
||||
template: '<div data-testid="tool-panel-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/PointerZone.vue', () => ({
|
||||
default: {
|
||||
name: 'PointerZoneStub',
|
||||
props: ['toolManager', 'panZoom'],
|
||||
template: '<div data-testid="pointer-zone-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/SidePanel.vue', () => ({
|
||||
default: {
|
||||
name: 'SidePanelStub',
|
||||
props: ['toolManager'],
|
||||
template: '<div data-testid="side-panel-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({
|
||||
default: {
|
||||
name: 'BrushCursorStub',
|
||||
props: ['containerRef'],
|
||||
template: '<div data-testid="brush-cursor-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const observeSpy = vi.fn()
|
||||
const disconnectSpy = vi.fn()
|
||||
let lastResizeCallback: ResizeObserverCallback | null = null
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = observeSpy
|
||||
disconnect = disconnectSpy
|
||||
unobserve = vi.fn()
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
lastResizeCallback = cb
|
||||
}
|
||||
}
|
||||
|
||||
// `node` only flows into mocked `loader.loadFromNode`, so a typed sentinel
|
||||
// with a stable identity is enough — we never read its fields.
|
||||
const fakeNode = { id: 1, title: 'test-node' } as unknown as LGraphNode
|
||||
|
||||
const renderContent = () =>
|
||||
render(MaskEditorContent, { props: { node: fakeNode } })
|
||||
|
||||
let originalResizeObserver: typeof ResizeObserver | undefined
|
||||
|
||||
describe('MaskEditorContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMockStore()
|
||||
mockMaskEditorLoader.loadFromNode.mockResolvedValue(undefined)
|
||||
mockImageLoader.loadImages.mockResolvedValue({ width: 100, height: 100 })
|
||||
mockPanZoom.initializeCanvasPanZoom.mockResolvedValue(undefined)
|
||||
mockBrushDrawing.initGPUResources.mockResolvedValue(undefined)
|
||||
originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver =
|
||||
originalResizeObserver as unknown as typeof ResizeObserver
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('should add keyboard listeners on mount', () => {
|
||||
renderContent()
|
||||
expect(mockKeyboard.addListeners).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should observe the container with a ResizeObserver', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(observeSpy).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should invalidate pan/zoom on resize', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(observeSpy).toHaveBeenCalled())
|
||||
mockPanZoom.invalidatePanZoom.mockClear()
|
||||
|
||||
lastResizeCallback?.([], {} as ResizeObserver)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockPanZoom.invalidatePanZoom).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should assign canvas refs to the store before init', async () => {
|
||||
renderContent()
|
||||
await waitFor(() => expect(mockStore.maskCanvas).not.toBeNull())
|
||||
expect(mockStore.rgbCanvas).not.toBeNull()
|
||||
expect(mockStore.imgCanvas).not.toBeNull()
|
||||
expect(mockStore.canvasContainer).not.toBeNull()
|
||||
expect(mockStore.canvasBackground).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init flow', () => {
|
||||
it('should run the init chain in the documented order', async () => {
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const orderOf = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]
|
||||
|
||||
expect(orderOf(mockMaskEditorLoader.loadFromNode)).toBeLessThan(
|
||||
orderOf(mockImageLoader.loadImages)
|
||||
)
|
||||
expect(orderOf(mockImageLoader.loadImages)).toBeLessThan(
|
||||
orderOf(mockPanZoom.initializeCanvasPanZoom)
|
||||
)
|
||||
expect(orderOf(mockPanZoom.initializeCanvasPanZoom)).toBeLessThan(
|
||||
orderOf(mockCanvasHistory.saveInitialState)
|
||||
)
|
||||
expect(orderOf(mockCanvasHistory.saveInitialState)).toBeLessThan(
|
||||
orderOf(mockBrushDrawing.initGPUResources)
|
||||
)
|
||||
expect(orderOf(mockBrushDrawing.initGPUResources)).toBeLessThan(
|
||||
orderOf(mockBrushDrawing.initPreviewCanvas)
|
||||
)
|
||||
expect(mockMaskEditorLoader.loadFromNode).toHaveBeenCalledWith(fakeNode)
|
||||
})
|
||||
|
||||
it('should reveal the child UI components after init succeeds', async () => {
|
||||
renderContent()
|
||||
|
||||
expect(await screen.findByTestId('tool-panel-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('pointer-zone-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('side-panel-stub')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('brush-cursor-stub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should size the GPU preview canvas to match the mask canvas', async () => {
|
||||
// Force the mask canvas to non-default dimensions during init so the
|
||||
// assertion below proves the source actually copies width/height across
|
||||
// (default 300x150 on both would make the test tautological).
|
||||
mockBrushDrawing.initGPUResources.mockImplementationOnce(async () => {
|
||||
if (mockStore.maskCanvas) {
|
||||
mockStore.maskCanvas.width = 999
|
||||
mockStore.maskCanvas.height = 777
|
||||
}
|
||||
})
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const previewCanvas = mockBrushDrawing.initPreviewCanvas.mock
|
||||
.calls[0][0] as HTMLCanvasElement
|
||||
expect(previewCanvas.width).toBe(999)
|
||||
expect(previewCanvas.height).toBe(777)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init error', () => {
|
||||
it('should close the dialog and log when loader.loadFromNode rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockMaskEditorLoader.loadFromNode.mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorContent] Initialization failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should close the dialog and log when initializeCanvasPanZoom rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPanZoom.initializeCanvasPanZoom.mockRejectedValueOnce(
|
||||
new Error('panzoom failed')
|
||||
)
|
||||
|
||||
renderContent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorContent] Initialization failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag handling', () => {
|
||||
it('should prevent default on dragstart with Ctrl held', () => {
|
||||
renderContent()
|
||||
const root = screen.getByTestId('mask-editor-root')
|
||||
|
||||
const event = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
// happy-dom doesn't propagate ctrlKey through the DragEvent constructor.
|
||||
Object.defineProperty(event, 'ctrlKey', { value: true })
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('should not prevent default on plain dragstart without Ctrl', () => {
|
||||
renderContent()
|
||||
const root = screen.getByTestId('mask-editor-root')
|
||||
|
||||
const event = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(event, 'ctrlKey', { value: false })
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount cleanup', () => {
|
||||
it('should run the full cleanup chain on unmount', async () => {
|
||||
const { unmount } = renderContent()
|
||||
await waitFor(() =>
|
||||
expect(mockBrushDrawing.initGPUResources).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockBrushDrawing.saveBrushSettings).toHaveBeenCalledTimes(1)
|
||||
expect(mockKeyboard.removeListeners).toHaveBeenCalledTimes(1)
|
||||
expect(mockCanvasHistory.clearStates).toHaveBeenCalledTimes(1)
|
||||
expect(mockStore.resetState).toHaveBeenCalledTimes(1)
|
||||
expect(mockDataStore.reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
data-testid="mask-editor-root"
|
||||
class="maskEditor-dialog-root flex size-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
|
||||
262
src/components/maskeditor/dialog/TopBarHeader.test.ts
Normal file
262
src/components/maskeditor/dialog/TopBarHeader.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
|
||||
const mockCanvasHistory = vi.hoisted(() => ({
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
}))
|
||||
|
||||
const initialMock = () =>
|
||||
reactive({
|
||||
canvasHistory: mockCanvasHistory,
|
||||
brushVisible: true,
|
||||
triggerClear: vi.fn()
|
||||
})
|
||||
|
||||
let mockStore: ReturnType<typeof initialMock>
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCanvasTools = vi.hoisted(() => ({
|
||||
invertMask: vi.fn(),
|
||||
clearMask: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCanvasTransform = vi.hoisted(() => ({
|
||||
rotateCounterclockwise: vi.fn().mockResolvedValue(undefined),
|
||||
rotateClockwise: vi.fn().mockResolvedValue(undefined),
|
||||
mirrorHorizontal: vi.fn().mockResolvedValue(undefined),
|
||||
mirrorVertical: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockSaver = vi.hoisted(() => ({
|
||||
save: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: () => mockStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
|
||||
useCanvasTools: () => mockCanvasTools
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasTransform', () => ({
|
||||
useCanvasTransform: () => mockCanvasTransform
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditorSaver', () => ({
|
||||
useMaskEditorSaver: () => mockSaver
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
name: 'ButtonStub',
|
||||
props: ['variant', 'disabled'],
|
||||
template:
|
||||
'<button :data-variant="variant" :disabled="disabled"><slot /></button>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
save: 'Save',
|
||||
saving: 'Saving',
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
maskEditor: {
|
||||
title: 'Mask Editor',
|
||||
invert: 'Invert',
|
||||
clear: 'Clear',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
rotateLeft: 'Rotate Left',
|
||||
rotateRight: 'Rotate Right',
|
||||
mirrorHorizontal: 'Mirror Horizontal',
|
||||
mirrorVertical: 'Mirror Vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderHeader = () => render(TopBarHeader, { global: { plugins: [i18n] } })
|
||||
|
||||
describe('TopBarHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore = initialMock()
|
||||
})
|
||||
|
||||
describe('title', () => {
|
||||
it('should render the localized title', () => {
|
||||
renderHeader()
|
||||
expect(screen.getByText('Mask Editor')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('history buttons', () => {
|
||||
it('should call canvasHistory.undo when undo button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
|
||||
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call canvasHistory.redo when redo button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Redo' }))
|
||||
|
||||
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas transform buttons', () => {
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise'],
|
||||
['Rotate Right', 'rotateClockwise'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal'],
|
||||
['Mirror Vertical', 'mirrorVertical']
|
||||
] as const)(
|
||||
'should call canvasTransform.%s when %s button is clicked',
|
||||
async (label, method) => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
expect(mockCanvasTransform[method]).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
|
||||
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
|
||||
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
|
||||
] as const)(
|
||||
'should swallow and log errors from %s',
|
||||
async (label, method, expectedMsg) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[TopBarHeader] ${expectedMsg}`,
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('mask edit buttons', () => {
|
||||
it('should call canvasTools.invertMask on Invert click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Invert' }))
|
||||
|
||||
expect(mockCanvasTools.invertMask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearMask and store.triggerClear on Clear click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
|
||||
expect(mockCanvasTools.clearMask).toHaveBeenCalledTimes(1)
|
||||
expect(mockStore.triggerClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save', () => {
|
||||
it('should hide brush, save, and close the dialog on success', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
mockStore.brushVisible = true
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockSaver.save).toHaveBeenCalledTimes(1)
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should switch the button text to "Saving" and disable the button while saving', async () => {
|
||||
let resolve!: () => void
|
||||
mockSaver.save.mockReturnValueOnce(
|
||||
new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
const clickPromise = user.click(
|
||||
screen.getByRole('button', { name: /save/i })
|
||||
)
|
||||
const savingBtn = await screen.findByRole('button', { name: 'Saving' })
|
||||
expect(savingBtn).toBeDisabled()
|
||||
|
||||
resolve()
|
||||
await clickPromise
|
||||
})
|
||||
|
||||
it('should restore brush + button state and log on save failure', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSaver.save.mockRejectedValueOnce(new Error('save failed'))
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
expect(mockStore.brushVisible).toBe(true)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[TopBarHeader] Save failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockDialogStore.closeDialog).not.toHaveBeenCalled()
|
||||
// After failure, the Save button reads "Save" again (not "Saving")
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save/i }).textContent?.trim()
|
||||
).toBe('Save')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should close the dialog with the global-mask-editor key', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-mask-editor'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,19 @@
|
||||
<div v-if="badge.tooltip" class="text-xs">
|
||||
{{ badge.tooltip }}
|
||||
</div>
|
||||
<template v-if="badge.popoverLinks?.length">
|
||||
<hr class="border-border-default" />
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -96,6 +109,19 @@
|
||||
<div v-if="badge.tooltip" class="text-xs">
|
||||
{{ badge.tooltip }}
|
||||
</div>
|
||||
<template v-if="badge.popoverLinks?.length">
|
||||
<hr class="border-border-default" />
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -103,10 +129,15 @@
|
||||
<!-- Full mode: Icon + Label + Text -->
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="badge.tooltip"
|
||||
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
|
||||
v-tooltip="badge.popoverLinks?.length ? undefined : badge.tooltip"
|
||||
class="relative flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
:class="[
|
||||
{ 'flex-row-reverse': reverseOrder },
|
||||
noPadding ? '' : 'px-3',
|
||||
badge.popoverLinks?.length ? clickableClasses : ''
|
||||
]"
|
||||
:style="menuBackgroundStyle"
|
||||
@click="badge.popoverLinks?.length ? togglePopover($event) : undefined"
|
||||
>
|
||||
<i
|
||||
v-if="iconClass"
|
||||
@@ -123,6 +154,30 @@
|
||||
<div class="font-inter text-sm" :class="textClasses">
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
<Popover
|
||||
v-if="badge.popoverLinks?.length"
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
>
|
||||
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -41,3 +41,15 @@ export function getComfyPlatformBaseUrl(): string {
|
||||
BUILD_TIME_PLATFORM_BASE_URL
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Comfy Cloud API base URL (as reported by the backend) to its paired
|
||||
* platform URL where users manage their account / API keys. Returns null for
|
||||
* unknown bases so callers can hide the link rather than guess.
|
||||
*/
|
||||
export function getPlatformBaseUrlForApiBase(apiBase: string): string | null {
|
||||
const normalized = apiBase.replace(/\/+$/, '')
|
||||
if (normalized === PROD_API_BASE_URL) return PROD_PLATFORM_BASE_URL
|
||||
if (normalized === STAGING_API_BASE_URL) return STAGING_PLATFORM_BASE_URL
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
|
||||
if (isNightly && !isCloud) {
|
||||
await import('./nightlyBadges')
|
||||
}
|
||||
|
||||
// PR preview build badge
|
||||
if (__CI_PR_NUMBER__) {
|
||||
await import('./prPreviewBadges')
|
||||
}
|
||||
|
||||
@@ -469,6 +469,31 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('adapter-driven kind queries', () => {
|
||||
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
|
||||
const adapter = kind === null ? null : { kind }
|
||||
Object.assign(ctx.load3d, {
|
||||
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
|
||||
})
|
||||
}
|
||||
|
||||
it('isSplatModel is true only when the current adapter kind is "splat"', () => {
|
||||
makeWithAdapter('splat')
|
||||
expect(ctx.load3d.isSplatModel()).toBe(true)
|
||||
makeWithAdapter('mesh')
|
||||
expect(ctx.load3d.isSplatModel()).toBe(false)
|
||||
makeWithAdapter(null)
|
||||
expect(ctx.load3d.isSplatModel()).toBe(false)
|
||||
})
|
||||
|
||||
it('isPlyModel is true only when the current adapter kind is "pointCloud"', () => {
|
||||
makeWithAdapter('pointCloud')
|
||||
expect(ctx.load3d.isPlyModel()).toBe(true)
|
||||
makeWithAdapter('mesh')
|
||||
expect(ctx.load3d.isPlyModel()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -24,6 +22,7 @@ import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
@@ -78,10 +77,7 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
@@ -217,69 +213,12 @@ class Load3d {
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu on the Three.js canvas
|
||||
* Detects right-click vs right-drag to show menu only on click
|
||||
*/
|
||||
private initContextMenu(): void {
|
||||
const canvas = this.renderer.domElement
|
||||
|
||||
this.contextMenuAbortController = new AbortController()
|
||||
const { signal } = this.contextMenuAbortController
|
||||
|
||||
const mousedownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
this.rightMouseStart = { x: e.clientX, y: e.clientY }
|
||||
this.rightMouseMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
const mousemoveHandler = (e: MouseEvent) => {
|
||||
if (e.buttons === 2) {
|
||||
if (
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
) {
|
||||
this.rightMouseMoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
if (this.isViewerMode) return
|
||||
|
||||
const wasDragging =
|
||||
this.rightMouseMoved ||
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
|
||||
this.rightMouseMoved = false
|
||||
|
||||
if (wasDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
this.showNodeContextMenu(e)
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', mousedownHandler, { signal })
|
||||
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
|
||||
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
|
||||
}
|
||||
|
||||
private showNodeContextMenu(event: MouseEvent): void {
|
||||
if (this.onContextMenuCallback) {
|
||||
this.onContextMenuCallback(event)
|
||||
}
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
@@ -628,11 +567,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.modelManager.containsSplatMesh()
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.modelManager.originalModel instanceof THREE.BufferGeometry
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -915,10 +854,8 @@ class Load3d {
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
}
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
|
||||
530
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
530
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
MaterialMode,
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
function makeEventManagerStub() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type ModelManagerStub = {
|
||||
clearModel: ReturnType<typeof vi.fn>
|
||||
setupModel: ReturnType<typeof vi.fn>
|
||||
setOriginalModel: ReturnType<typeof vi.fn>
|
||||
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
|
||||
standardMaterial: THREE.MeshStandardMaterial
|
||||
materialMode: MaterialMode
|
||||
originalFileName: string | null
|
||||
originalURL: string | null
|
||||
}
|
||||
|
||||
function makeModelManagerStub(): ModelManagerStub {
|
||||
return {
|
||||
clearModel: vi.fn(),
|
||||
setupModel: vi.fn().mockResolvedValue(undefined),
|
||||
setOriginalModel: vi.fn(),
|
||||
originalMaterials: new WeakMap(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original',
|
||||
originalFileName: 'model',
|
||||
originalURL: null
|
||||
}
|
||||
}
|
||||
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities = {}
|
||||
load = meshLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./PointCloudModelAdapter', () => ({
|
||||
PointCloudModelAdapter: class {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = {}
|
||||
load = pointCloudLoad
|
||||
},
|
||||
getPLYEngine: () => getPLYEngineMock()
|
||||
}))
|
||||
|
||||
vi.mock('./SplatModelAdapter', () => ({
|
||||
SplatModelAdapter: class {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities = {}
|
||||
load = splatLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
const modelManager = makeModelManagerStub()
|
||||
const eventManager = makeEventManagerStub()
|
||||
const lm = new LoaderManager(
|
||||
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoaderManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
meshLoad.mockResolvedValue(null)
|
||||
splatLoad.mockResolvedValue(null)
|
||||
pointCloudLoad.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('getCurrentAdapter', () => {
|
||||
it('returns null before any model loads', () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
})
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.xyz')
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null when the adapter rejects', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
// Seed with a previously-successful mesh load so we can prove a later
|
||||
// failed splat load does not leave the splat adapter published.
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
splatLoad.mockRejectedValueOnce(new Error('boom'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.splat')
|
||||
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null when the adapter resolves null (parse failure)', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
pointCloudLoad.mockResolvedValueOnce(null)
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel ordering', () => {
|
||||
it('keeps the old adapter current while clearModel runs (so future dispose hooks see it)', async () => {
|
||||
const oldAdapter = {
|
||||
kind: 'splat' as const,
|
||||
extensions: ['splat'] as const,
|
||||
capabilities: {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 5
|
||||
},
|
||||
load: vi.fn().mockResolvedValue(null)
|
||||
} satisfies ModelAdapter
|
||||
|
||||
const modelManager = {
|
||||
originalMaterials: new WeakMap(),
|
||||
clearModel: vi.fn(),
|
||||
setupModel: vi.fn()
|
||||
} as unknown as ModelManagerInterface
|
||||
const eventManager: EventManagerInterface = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
|
||||
// Prime the loader with an active adapter, then trigger a new load.
|
||||
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
|
||||
oldAdapter
|
||||
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => {
|
||||
adapterDuringClear = lm.getCurrentAdapter()
|
||||
}
|
||||
)
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=input&subfolder=&filename=a.splat',
|
||||
'a.splat'
|
||||
)
|
||||
|
||||
expect(adapterDuringClear).toBe(oldAdapter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickAdapter', () => {
|
||||
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel', () => {
|
||||
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
|
||||
const { lm, eventManager, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingStart',
|
||||
null
|
||||
)
|
||||
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
|
||||
})
|
||||
|
||||
it('clears any existing model before routing to the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const order: string[] = []
|
||||
modelManager.clearModel.mockImplementation(() => order.push('clear'))
|
||||
meshLoad.mockImplementationOnce(async () => {
|
||||
order.push('load')
|
||||
return null
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(order).toEqual(['clear', 'load'])
|
||||
})
|
||||
|
||||
it('derives originalFileName from an explicit originalFileName argument', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('my-cube')
|
||||
})
|
||||
|
||||
it('derives originalFileName from the URL filename param when no override is given', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('cube')
|
||||
})
|
||||
|
||||
it('falls back to "model" when the URL has no filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('model')
|
||||
})
|
||||
|
||||
it('alerts when the file extension cannot be determined', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.couldNotDetermineFileType'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
expect(meshLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
|
||||
})
|
||||
|
||||
it('skips setupModel when the adapter returns null', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(null)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
)
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setOriginalModel: expect.any(Function),
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(splatLoad).toHaveBeenCalled()
|
||||
expect(pointCloudLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
const err = new Error('boom')
|
||||
meshLoad.mockRejectedValueOnce(err)
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards the result of a stale load when a newer one has started', async () => {
|
||||
const { lm, modelManager, eventManager } = makeLoaderManager()
|
||||
|
||||
let resolveFirst!: (value: THREE.Object3D) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((r) => {
|
||||
resolveFirst = r
|
||||
})
|
||||
const firstModel = new THREE.Object3D()
|
||||
firstModel.name = 'first'
|
||||
const secondModel = new THREE.Object3D()
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
resolveFirst(firstModel)
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
|
||||
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('logs and drops the load when the URL is missing a filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Missing filename in URL:',
|
||||
'api/view?type=output'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.MeshBasicMaterial()
|
||||
)
|
||||
const mat = new THREE.MeshStandardMaterial()
|
||||
capturedCtx!.setOriginalModel(mesh)
|
||||
capturedCtx!.registerOriginalMaterial(mesh, mat)
|
||||
|
||||
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
|
||||
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
|
||||
})
|
||||
|
||||
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
modelManager.materialMode = 'wireframe'
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
|
||||
expect(capturedCtx!.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
|
||||
let rejectFirst!: (err: unknown) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
|
||||
rejectFirst = r
|
||||
})
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
rejectFirst(new Error('stale failure'))
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,60 +1,49 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included)
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type LoaderManagerInterface,
|
||||
type ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
*/
|
||||
function defaultAdapters(): ModelAdapter[] {
|
||||
return [
|
||||
new MeshModelAdapter(),
|
||||
new PointCloudModelAdapter(),
|
||||
new SplatModelAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
plyLoader: PLYLoader
|
||||
fastPlyLoader: FastPLYLoader
|
||||
|
||||
private modelManager: ModelManagerInterface
|
||||
private eventManager: EventManagerInterface
|
||||
private readonly modelManager: ModelManagerInterface
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private readonly adapters: ModelAdapter[]
|
||||
private currentLoadId: number = 0
|
||||
private _currentAdapter: ModelAdapter | null = null
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface
|
||||
eventManager: EventManagerInterface,
|
||||
adapters?: readonly ModelAdapter[]
|
||||
) {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
this.adapters = adapters ? [...adapters] : defaultAdapters()
|
||||
}
|
||||
|
||||
this.gltfLoader = new GLTFLoader()
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
// Set worker URL for Vite compatibility
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
this.plyLoader = new PLYLoader()
|
||||
this.fastPlyLoader = new FastPLYLoader()
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
return this._currentAdapter
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -68,6 +57,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this._currentAdapter = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -80,12 +70,9 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
} else {
|
||||
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
||||
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (filename) {
|
||||
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
|
||||
} else {
|
||||
this.modelManager.originalFileName = 'model'
|
||||
}
|
||||
this.modelManager.originalFileName = filename
|
||||
? filename.split('.')[0] || 'model'
|
||||
: 'model'
|
||||
}
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -93,19 +80,21 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
return
|
||||
}
|
||||
|
||||
const model = await this.loadModelInternal(url, fileExtension)
|
||||
const result = await this.loadModelInternal(url, fileExtension)
|
||||
|
||||
if (loadId !== this.currentLoadId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await this.modelManager.setupModel(model)
|
||||
if (result && result.model) {
|
||||
this._currentAdapter = result.adapter
|
||||
await this.modelManager.setupModel(result.model)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
} catch (error) {
|
||||
if (loadId === this.currentLoadId) {
|
||||
this._currentAdapter = null
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
@@ -113,26 +102,50 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
const match = this.adapters.find((adapter) =>
|
||||
adapter.extensions.includes(extension)
|
||||
)
|
||||
if (!match) return null
|
||||
|
||||
// PLY may be routed through the splat adapter when the PLYEngine setting
|
||||
// is sparkjs. Only honor the routing when both adapters are registered.
|
||||
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
|
||||
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
|
||||
if (splat) return splat
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
const mm = this.modelManager
|
||||
return {
|
||||
setOriginalModel: (model) => mm.setOriginalModel(model),
|
||||
registerOriginalMaterial: (mesh, material) =>
|
||||
mm.originalMaterials.set(mesh, material),
|
||||
get standardMaterial() {
|
||||
return mm.standardMaterial
|
||||
},
|
||||
get materialMode() {
|
||||
return mm.materialMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
let model: THREE.Object3D | null = null
|
||||
|
||||
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
|
||||
const filename = params.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
console.error('Missing filename in URL:', url)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
|
||||
|
||||
const subfolder = params.get('subfolder') ?? ''
|
||||
|
||||
const path =
|
||||
'api/view?type=' +
|
||||
loadRootFolder +
|
||||
@@ -140,217 +153,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
switch (fileExtension) {
|
||||
case 'stl':
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
this.modelManager.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
this.modelManager.standardMaterial
|
||||
)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
model = group
|
||||
break
|
||||
|
||||
case 'fbx':
|
||||
this.fbxLoader.setPath(path)
|
||||
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(fbxModel)
|
||||
model = fbxModel
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'obj':
|
||||
if (this.modelManager.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
|
||||
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
|
||||
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(gltf)
|
||||
model = gltf.scene
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'ply':
|
||||
model = await this.loadPLY(path, filename)
|
||||
break
|
||||
|
||||
case 'spz':
|
||||
case 'splat':
|
||||
case 'ksplat':
|
||||
model = await this.loadSplat(path, filename)
|
||||
break
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async fetchModelData(path: string, filename: string) {
|
||||
const route =
|
||||
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
private async loadSplat(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
this.modelManager.setOriginalModel(splatMesh)
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
private async loadPLY(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
|
||||
if (plyEngine === 'sparkjs') {
|
||||
return this.loadSplat(path, filename)
|
||||
}
|
||||
|
||||
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
let plyGeometry: THREE.BufferGeometry
|
||||
|
||||
if (isASCII && plyEngine === 'fastply') {
|
||||
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
|
||||
} else {
|
||||
this.plyLoader.setPath(path)
|
||||
plyGeometry = this.plyLoader.parse(arrayBuffer)
|
||||
}
|
||||
|
||||
this.modelManager.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const materialMode = this.modelManager.materialMode
|
||||
|
||||
// Use Points rendering for pointCloud mode (better for point clouds)
|
||||
if (materialMode === 'pointCloud') {
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
|
||||
this.modelManager.originalMaterials.set(
|
||||
plyPoints as unknown as THREE.Mesh,
|
||||
pointMaterial
|
||||
)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyPoints)
|
||||
return plyGroup
|
||||
}
|
||||
|
||||
// Use Mesh rendering for other modes
|
||||
let plyMaterial: THREE.Material
|
||||
|
||||
if (hasVertexColors) {
|
||||
plyMaterial = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else {
|
||||
plyMaterial = this.modelManager.standardMaterial.clone()
|
||||
plyMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
|
||||
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyMesh)
|
||||
return plyGroup
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return { adapter, model }
|
||||
}
|
||||
}
|
||||
|
||||
302
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
302
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
const stlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
|
||||
}
|
||||
const fbxLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
const gltfLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
|
||||
}
|
||||
const mtlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
|
||||
}
|
||||
const objLoaderStub = {
|
||||
setWorkerUrl: vi.fn(),
|
||||
setMaterials: vi.fn(),
|
||||
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
|
||||
STLLoader: class {
|
||||
setPath = stlLoaderStub.setPath
|
||||
loadAsync = stlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
|
||||
FBXLoader: class {
|
||||
setPath = fbxLoaderStub.setPath
|
||||
loadAsync = fbxLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
|
||||
GLTFLoader: class {
|
||||
setPath = gltfLoaderStub.setPath
|
||||
loadAsync = gltfLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
|
||||
MTLLoader: class {
|
||||
setPath = mtlLoaderStub.setPath
|
||||
loadAsync = mtlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2', () => ({
|
||||
OBJLoader2Parallel: class {
|
||||
setWorkerUrl = objLoaderStub.setWorkerUrl
|
||||
setMaterials = objLoaderStub.setMaterials
|
||||
loadAsync = objLoaderStub.loadAsync
|
||||
},
|
||||
MtlObjBridge: {
|
||||
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
|
||||
default: 'mock-worker-url'
|
||||
}))
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
function makeFbxLikeGroup(): THREE.Group {
|
||||
const group = new THREE.Group()
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
describe('MeshModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('identifies as a mesh adapter with full capabilities', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect(adapter.kind).toBe('mesh')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(true)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(true)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('handles the expected mesh extensions', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual([
|
||||
'stl',
|
||||
'fbx',
|
||||
'obj',
|
||||
'gltf',
|
||||
'glb'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch fallbacks', () => {
|
||||
it('returns null when the filename extension belongs to another adapter', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unknown extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a filename without an extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'noextension')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('STL loader path', () => {
|
||||
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
|
||||
|
||||
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FBX loader path', () => {
|
||||
it('loads an FBX model and registers its mesh materials', async () => {
|
||||
const fbxModel = makeFbxLikeGroup()
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
|
||||
|
||||
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
const group = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
group.add(skinned)
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(group)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OBJ loader path', () => {
|
||||
it('attempts the MTL sidecar in original material mode', async () => {
|
||||
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
|
||||
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('swallows MTL load errors and continues without materials', async () => {
|
||||
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view/',
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the MTL attempt for non-original material modes', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('registers materials for each mesh child', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext('wireframe')
|
||||
await adapter.load(ctx, '/api/view/', 'cube.obj')
|
||||
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GLTF loader path', () => {
|
||||
it('loads a .glb and returns the scene with vertex normals computed', async () => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
|
||||
const scene = new THREE.Group()
|
||||
scene.add(mesh)
|
||||
const gltf = { scene }
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
|
||||
|
||||
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
|
||||
expect(computeNormals).toHaveBeenCalled()
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
|
||||
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
|
||||
const scene = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
scene.add(skinned)
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
155
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
155
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included).
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
private readonly gltfLoader = new GLTFLoader()
|
||||
private readonly objLoader: OBJLoader2Parallel
|
||||
private readonly mtlLoader = new MTLLoader()
|
||||
private readonly fbxLoader = new FBXLoader()
|
||||
private readonly stlLoader = new STLLoader()
|
||||
|
||||
constructor() {
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'stl':
|
||||
return this.loadSTL(ctx, path, filename)
|
||||
case 'fbx':
|
||||
return this.loadFBX(ctx, path, filename)
|
||||
case 'obj':
|
||||
return this.loadOBJ(ctx, path, filename)
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
return this.loadGLTF(ctx, path, filename)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
private async loadFBX(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.fbxLoader.setPath(path)
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(fbxModel)
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return fbxModel
|
||||
}
|
||||
|
||||
private async loadOBJ(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
if (ctx.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
const model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
}
|
||||
})
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async loadGLTF(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(gltf)
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return gltf.scene
|
||||
}
|
||||
}
|
||||
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DEFAULT_MODEL_CAPABILITIES', () => {
|
||||
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
|
||||
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchModelData', () => {
|
||||
const mockFetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the arrayBuffer on a successful response', async () => {
|
||||
const buf = new ArrayBuffer(8)
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(buf)
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
|
||||
|
||||
expect(result).toBe(buf)
|
||||
})
|
||||
|
||||
it('throws with status code when the response is not ok', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
|
||||
).rejects.toThrow('Failed to fetch model: 404')
|
||||
})
|
||||
|
||||
it('strips the leading api/ prefix and encodes the filename', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData(
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'a b c.ply'
|
||||
)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
|
||||
)
|
||||
})
|
||||
|
||||
it('prepends a single slash when the path has no api/ prefix', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData('custom?filename=', 'scene.splat')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
|
||||
})
|
||||
})
|
||||
88
src/extensions/core/load3d/ModelAdapter.ts
Normal file
88
src/extensions/core/load3d/ModelAdapter.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { MaterialMode } from './interfaces'
|
||||
|
||||
export interface ModelLoadContext {
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
|
||||
registerOriginalMaterial(
|
||||
mesh: THREE.Mesh,
|
||||
material: THREE.Material | THREE.Material[]
|
||||
): void
|
||||
readonly standardMaterial: THREE.MeshStandardMaterial
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
|
||||
* action should run. Splats render self-sized and are placed at a fixed
|
||||
* camera distance instead.
|
||||
*/
|
||||
fitToViewer: boolean
|
||||
/**
|
||||
* Whether a material mode change must rebuild the scene object instead of
|
||||
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
|
||||
* Points swap); false for regular meshes and self-rendering splats.
|
||||
*/
|
||||
requiresMaterialRebuild: boolean
|
||||
/**
|
||||
* Whether the gizmo transform UI (translate/rotate/scale) should be
|
||||
* exposed for this model type. False for adapters whose already-normalized
|
||||
* output makes user transforms meaningless (PLY point cloud).
|
||||
*/
|
||||
gizmoTransform: boolean
|
||||
/** Whether scene-lighting controls apply. False for self-lit formats. */
|
||||
lighting: boolean
|
||||
/** Whether the model can be exported (GLB/OBJ/STL). */
|
||||
exportable: boolean
|
||||
/**
|
||||
* Material modes offered in the UI for this format. An empty array hides
|
||||
* the material-mode dropdown entirely.
|
||||
*/
|
||||
materialModes: readonly MaterialMode[]
|
||||
/**
|
||||
* World-space target size along the largest dimension after
|
||||
* fit-to-viewer normalization. Controls how large the model ends up
|
||||
* relative to the 20-unit scene grid; splats use a larger value so they
|
||||
* don't shrink to a quarter of the floor.
|
||||
*/
|
||||
fitTargetSize: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
readonly capabilities: ModelAdapterCapabilities
|
||||
load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
}
|
||||
|
||||
export async function fetchModelData(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
116
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
116
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
|
||||
|
||||
const mockSettingGet = vi.fn<(key: string) => unknown>()
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: mockSettingGet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
|
||||
PLYLoader: class {
|
||||
setPath = vi.fn()
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./loader/FastPLYLoader', () => ({
|
||||
FastPLYLoader: class {
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
if (withColors) {
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
|
||||
)
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
describe('PointCloudModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReset()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('handles the ply extension', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['ply'])
|
||||
})
|
||||
|
||||
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect(adapter.kind).toBe('pointCloud')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(false)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(false)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'pointCloud',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReturnValue('three')
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
|
||||
new ArrayBuffer(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('original')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Mesh)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns a Group containing Points when materialMode is pointCloud', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('pointCloud')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
})
|
||||
})
|
||||
120
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
120
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as THREE from 'three'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export function getPLYEngine(): string {
|
||||
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
}
|
||||
|
||||
export class PointCloudModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
private readonly plyLoader = new PLYLoader()
|
||||
private readonly fastPlyLoader = new FastPLYLoader()
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
const plyGeometry =
|
||||
isASCII && getPLYEngine() === 'fastply'
|
||||
? this.fastPlyLoader.parse(arrayBuffer)
|
||||
: this.plyLoader.parse(arrayBuffer)
|
||||
|
||||
ctx.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
if (ctx.materialMode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
|
||||
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPointsGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
geometry.computeBoundingSphere()
|
||||
if (geometry.boundingSphere) {
|
||||
const { center, radius } = geometry.boundingSphere
|
||||
geometry.translate(-center.x, -center.y, -center.z)
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
geometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(geometry, pointMaterial)
|
||||
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(points)
|
||||
return group
|
||||
}
|
||||
|
||||
function buildMeshGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
const material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: ctx.standardMaterial.clone()
|
||||
|
||||
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
|
||||
material.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
ctx.registerOriginalMaterial(mesh, material)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
82
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
82
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
|
||||
const { splatMeshCtor } = vi.hoisted(() => ({
|
||||
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('@sparkjsdev/spark', async () => {
|
||||
const three = await import('three')
|
||||
return {
|
||||
SplatMesh: class extends three.Object3D {
|
||||
constructor(opts: { fileBytes: ArrayBuffer }) {
|
||||
super()
|
||||
splatMeshCtor(opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeContext(): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original'
|
||||
}
|
||||
}
|
||||
|
||||
describe('SplatModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
splatMeshCtor.mockReset()
|
||||
})
|
||||
|
||||
it('exposes splat capabilities on the adapter', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect(adapter.kind).toBe('splat')
|
||||
expect(adapter.capabilities.lighting).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
it('handles the Gaussian splat extensions', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
|
||||
})
|
||||
|
||||
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
|
||||
const buf = new ArrayBuffer(128)
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
|
||||
|
||||
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
|
||||
new Error('Failed to fetch model: 500')
|
||||
)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
await expect(
|
||||
adapter.load(makeContext(), '/api/view?', 'scene.splat')
|
||||
).rejects.toThrow('Failed to fetch model: 500')
|
||||
})
|
||||
})
|
||||
38
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
38
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,7 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import type { OBJLoader2Parallel } from 'wwobjloader2'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
@@ -203,12 +199,6 @@ export interface ModelManagerInterface {
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
|
||||
init(): void
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
|
||||
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
|
||||
function rightMouse(type: string, x: number, y: number, buttons = 2) {
|
||||
const event = new MouseEvent(type, {
|
||||
button: 2,
|
||||
buttons,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
describe('attachContextMenuGuard', () => {
|
||||
let target: HTMLElement
|
||||
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
|
||||
let dispose: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
target = document.createElement('div')
|
||||
document.body.appendChild(target)
|
||||
onMenu = vi.fn<(event: MouseEvent) => void>()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('invokes onMenu for a right-click without drag movement', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preventDefault is called on the contextmenu event when menu fires', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 0, 0))
|
||||
const contextEvent = rightMouse('contextmenu', 0, 0)
|
||||
target.dispatchEvent(contextEvent)
|
||||
|
||||
expect(contextEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 120, 120))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still fires onMenu when the mouse moved within the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 103, 104))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detects a drag from start to contextmenu even without mousemove events', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets drag state between right-clicks', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 50, 50))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores onMenu when isDisabled returns true', () => {
|
||||
let disabled = true
|
||||
dispose = attachContextMenuGuard(target, onMenu, {
|
||||
isDisabled: () => disabled
|
||||
})
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
disabled = false
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops listening after dispose', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
dispose()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mousemove events without the right button held', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
type ContextMenuGuardOptions = {
|
||||
isDisabled?: () => boolean
|
||||
dragThreshold?: number
|
||||
}
|
||||
|
||||
export function attachContextMenuGuard(
|
||||
target: HTMLElement,
|
||||
onMenu: (event: MouseEvent) => void,
|
||||
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
|
||||
): () => void {
|
||||
const abort = new AbortController()
|
||||
const { signal } = abort
|
||||
|
||||
let start = { x: 0, y: 0 }
|
||||
let moved = false
|
||||
|
||||
target.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (e.button === 2) {
|
||||
start = { x: e.clientX, y: e.clientY }
|
||||
moved = false
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
if (
|
||||
e.buttons === 2 &&
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
if (isDisabled()) return
|
||||
|
||||
const wasDragging =
|
||||
moved ||
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
|
||||
moved = false
|
||||
|
||||
if (wasDragging) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMenu(e)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return () => abort.abort()
|
||||
}
|
||||
82
src/extensions/core/prPreviewBadges.ts
Normal file
82
src/extensions/core/prPreviewBadges.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { AboutPageBadge, TopbarBadge } from '@/types/comfy'
|
||||
|
||||
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
|
||||
const prNumber = __CI_PR_NUMBER__
|
||||
const author = __CI_PR_AUTHOR__
|
||||
const commit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const commitShort = commit ? commit.slice(0, 8) : ''
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const apiNodesEnabled = settingStore.get('Comfy.NodeBadge.ShowApiPricing')
|
||||
|
||||
const backendUrl = localStorage.getItem('comfyui-preview-backend-url') ?? '—'
|
||||
|
||||
const tooltipLines = [
|
||||
author ? `@${author}` : null,
|
||||
commitShort ? commitShort : null,
|
||||
`${t('prPreview.badge.tooltipBackendLabel')}${backendUrl}`,
|
||||
apiNodesEnabled
|
||||
? t('prPreview.badge.tooltipCloudApiNote')
|
||||
: t('prPreview.badge.tooltipCloudApiDisabled')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const popoverLinks = [
|
||||
{ label: `PR #${prNumber}`, url: `${REPO}/pull/${prNumber}` },
|
||||
...(author
|
||||
? [{ label: `@${author}`, url: `https://github.com/${author}` }]
|
||||
: []),
|
||||
...(commitShort
|
||||
? [{ label: commitShort, url: `${REPO}/commit/${commit}` }]
|
||||
: []),
|
||||
{ label: t('prPreview.badge.configureBackend'), url: '/connect' }
|
||||
]
|
||||
|
||||
const badgeText = commitShort ? `#${prNumber} · ${commitShort}` : `#${prNumber}`
|
||||
|
||||
const topbarBadges: TopbarBadge[] = [
|
||||
{
|
||||
label: t('prPreview.badge.label'),
|
||||
text: badgeText,
|
||||
variant: 'warning',
|
||||
tooltip: tooltipLines,
|
||||
popoverLinks
|
||||
}
|
||||
]
|
||||
|
||||
const aboutPageBadges: AboutPageBadge[] = [
|
||||
{
|
||||
label: `PR #${prNumber}`,
|
||||
url: `${REPO}/pull/${prNumber}`,
|
||||
icon: 'pi pi-github'
|
||||
},
|
||||
...(author
|
||||
? [
|
||||
{
|
||||
label: `@${author}`,
|
||||
url: `https://github.com/${author}`,
|
||||
icon: 'pi pi-user'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(commitShort
|
||||
? [
|
||||
{
|
||||
label: commitShort,
|
||||
url: `${REPO}/commit/${commit}`,
|
||||
icon: 'pi pi-code'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PrPreview.Badges',
|
||||
topbarBadges,
|
||||
aboutPageBadges
|
||||
})
|
||||
@@ -3798,5 +3798,72 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"connectionPanel": {
|
||||
"title": "ComfyUI Frontend Preview",
|
||||
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
|
||||
"previewWarningTitle": "⚠ This is a preview build of an in-flight pull request.",
|
||||
"previewWarningBody": "The UI may change rapidly as the branch is pushed and is under heavy development. Use it for testing and review only — never rely on any *.comfy-ui.pages.dev URL for production work.",
|
||||
"previewProvenance": "Built from {pr} ({commit}) by {author}.",
|
||||
"previewUnknownAuthor": "an unknown author",
|
||||
"previewTrustWarning": "Do not connect a ComfyUI instance you care about unless you trust the author of this PR — a malicious frontend can read and modify any workflow, model, or output on the connected backend.",
|
||||
"backendUrl": "Backend URL",
|
||||
"apiKey": "Comfy API Key",
|
||||
"apiKeyOptional": "(optional)",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyHint": "Only needed for cloud-API nodes (e.g. Flux, Kling). Saved to your browser.",
|
||||
"apiKeyTestOk": "API key is valid.",
|
||||
"apiKeyTestError": "Invalid or expired API key.",
|
||||
"apiKeyDisabledNotice": "The connected backend was started with --disable-api-nodes; cloud-API nodes are unavailable.",
|
||||
"test": "Test",
|
||||
"http": "HTTP",
|
||||
"ws": "WS",
|
||||
"status": "Connection Status",
|
||||
"connected": "Connected — backend is reachable.",
|
||||
"backendCloud": "Backend cloud API:",
|
||||
"cloudMismatch": "⚠ Cloud environment mismatch — this preview signs in via {frontend}, so tokens won't be accepted by the backend. Restart the backend with --comfy-api-base={frontend} (or use a frontend build that targets the backend's environment).",
|
||||
"getApiKeyLink": "→ Generate an API key for this cloud",
|
||||
"connectAndGo": "Connect & Open ComfyUI",
|
||||
"quickStart": "Quick Start with Comfy CLI",
|
||||
"quickStartDescription": "The fastest way to get ComfyUI running locally. No existing Python install required — uv handles it for you.",
|
||||
"step1InstallUv": "1. Install uv (macOS/Linux, then Windows):",
|
||||
"uvNote": "uv is a fast Python package manager that auto-installs Python itself, so you don't need Python preinstalled. After install, restart your terminal.",
|
||||
"step2InstallComfyui": "2. Install comfy-cli and ComfyUI:",
|
||||
"managerIncludedNote": "This also installs ComfyUI-Manager by default — it makes downloading missing models and custom nodes one-click, so workflows from others just work.",
|
||||
"managerTitle": "Why ComfyUI-Manager?",
|
||||
"managerDescription": "ComfyUI-Manager is bundled with comfy install. It auto-detects missing custom nodes and models referenced by any workflow you load, then installs them for you in one click — no more hunting GitHub repos or Hugging Face links by hand.",
|
||||
"managerLearnMore": "Learn more about ComfyUI-Manager →",
|
||||
"step3Launch": "3. Launch with CORS enabled:",
|
||||
"altManualSetup": "Alternative: I already have Python installed",
|
||||
"altPipDescription": "If you already have Python 3.10+ and pip available, you can install comfy-cli directly:",
|
||||
"altPipNote": "Note: older Python versions (<3.10) may fail to install some comfy-cli dependencies.",
|
||||
"altManagerDescription": "If you cloned ComfyUI manually, also install ComfyUI-Manager into custom_nodes/:",
|
||||
"guideDescription": "If you already have ComfyUI cloned, start it with CORS enabled from the repo root:",
|
||||
"corsNote": "The --enable-cors-header flag allows this preview page to communicate with your local backend.",
|
||||
"corsOriginNote": "The exact origin is pre-filled so ComfyUI can allow requests from this specific preview URL. Using * would block requests that include credentials.",
|
||||
"localAccess": "Local Network Access",
|
||||
"localAccessDescription": "Your browser may prompt for permission to access local network devices. Allow it so this page can reach your local ComfyUI instance.",
|
||||
"localAccessListenDescription": "To connect from another device on the same network (e.g. a phone or second computer), pass --listen so ComfyUI binds to all interfaces:",
|
||||
"localAccessListenNote": "Then enter your machine's LAN IP (e.g. http://192.168.1.x:8188) in the backend URL field above.",
|
||||
"source": "Source",
|
||||
"errorUnreachable": "Backend is unreachable. Ensure ComfyUI is running with CORS enabled.",
|
||||
"errorHttpFailed": "HTTP connection failed. Check the URL and CORS settings.",
|
||||
"errorWsFailed": "WebSocket connection failed. HTTP works — check firewall or proxy settings.",
|
||||
"buildPr": "PR #{prNumber}",
|
||||
"buildVersion": "v{version}",
|
||||
"tooltipVersion": "Version: {version}",
|
||||
"tooltipCommit": "Commit: {commit}",
|
||||
"tooltipBranch": "Branch: {branch}",
|
||||
"tooltipRunId": "Run ID: {runId}",
|
||||
"tooltipJobId": "Job ID: {jobId}"
|
||||
},
|
||||
"prPreview": {
|
||||
"badge": {
|
||||
"label": "PR",
|
||||
"tooltipBackendLabel": "Backend: ",
|
||||
"tooltipCloudApiNote": "Cloud API: see Settings → About",
|
||||
"tooltipCloudApiDisabled": "Cloud API: disabled",
|
||||
"configureBackend": "Configure backend →"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
src/platform/connectionPanel/backendReachable.test.ts
Normal file
112
src/platform/connectionPanel/backendReachable.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isBackendReachable } from './backendReachable'
|
||||
|
||||
const STORAGE_KEY = 'comfyui-preview-backend-url'
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) delete store[key]
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
||||
_store: store
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
|
||||
function mockFetchOnce(impl: () => Promise<Response> | Response) {
|
||||
vi.stubGlobal('fetch', vi.fn(impl))
|
||||
}
|
||||
|
||||
describe('isBackendReachable', () => {
|
||||
beforeEach(() => {
|
||||
mockLocalStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
})
|
||||
|
||||
it('returns true when system_stats responds with a system field', async () => {
|
||||
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
|
||||
mockFetchOnce(
|
||||
() =>
|
||||
new Response(JSON.stringify({ system: { os: 'darwin' } }), {
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
expect(await isBackendReachable()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when response is not ok', async () => {
|
||||
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
|
||||
mockFetchOnce(() => new Response('Not Found', { status: 404 }))
|
||||
|
||||
expect(await isBackendReachable()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when response is HTML (no system field)', async () => {
|
||||
// Simulates a Cloudflare-style SPA fallback returning index.html
|
||||
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
|
||||
mockFetchOnce(() => new Response(JSON.stringify({}), { status: 200 }))
|
||||
|
||||
expect(await isBackendReachable()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when fetch rejects (network error / CORS / aborted)', async () => {
|
||||
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
|
||||
mockFetchOnce(() => Promise.reject(new Error('network')))
|
||||
|
||||
expect(await isBackendReachable()).toBe(false)
|
||||
})
|
||||
|
||||
it('strips trailing slashes from the configured backend URL', async () => {
|
||||
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188///')
|
||||
const fetchSpy = vi.fn(
|
||||
() =>
|
||||
new Response(JSON.stringify({ system: { os: 'linux' } }), {
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await isBackendReachable()
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:8188/api/system_stats',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to same-origin when no backend URL is configured', async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
() =>
|
||||
new Response(JSON.stringify({ system: { os: 'linux' } }), {
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await isBackendReachable()
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/system_stats',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/platform/connectionPanel/backendReachable.ts
Normal file
47
src/platform/connectionPanel/backendReachable.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Probe the configured ComfyUI backend (local or remote-via-localStorage)
|
||||
* to confirm it serves the expected `/api/system_stats` shape. Used by the
|
||||
* router to decide whether to enter GraphView or redirect to /connect.
|
||||
*/
|
||||
|
||||
const BACKEND_URL_KEY = 'comfyui-preview-backend-url'
|
||||
const PROBE_TIMEOUT_MS = 3000
|
||||
|
||||
function resolveProbeBase(): string {
|
||||
const stored = localStorage.getItem(BACKEND_URL_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
// Only treat the stored value as a backend override when it's a
|
||||
// well-formed absolute URL — otherwise fall through to same-origin.
|
||||
const url = new URL(stored)
|
||||
return url.origin + url.pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
// Ignore malformed entries; same-origin probe is safer than a
|
||||
// relative URL that misses the router's subpath base.
|
||||
}
|
||||
}
|
||||
// Mirror ComfyApi's same-origin base so subpath deployments probe the
|
||||
// backend that would actually serve the app.
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export async function isBackendReachable(): Promise<boolean> {
|
||||
const apiBase = resolveProbeBase()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/system_stats`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const body = (await res.json()) as { system?: unknown }
|
||||
return !!body.system
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal file
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const COMFY_API_BASE_FLAG = '--comfy-api-base'
|
||||
const DEFAULT_CLOUD_API_BASE = 'https://api.comfy.org'
|
||||
|
||||
type SystemInfo = { argv?: string[]; comfy_api_base?: string }
|
||||
|
||||
function parseArgvApiBase(argv: string[] | undefined): string | undefined {
|
||||
if (!argv) return undefined
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === COMFY_API_BASE_FLAG && i + 1 < argv.length) return argv[i + 1]
|
||||
if (a.startsWith(`${COMFY_API_BASE_FLAG}=`))
|
||||
return a.slice(COMFY_API_BASE_FLAG.length + 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function resolveBackendCloudBase(
|
||||
system: SystemInfo | undefined
|
||||
): string {
|
||||
const explicit = system?.comfy_api_base
|
||||
if (explicit) return explicit.replace(/\/+$/, '')
|
||||
const fromArgv = parseArgvApiBase(system?.argv)
|
||||
return (fromArgv ?? DEFAULT_CLOUD_API_BASE).replace(/\/+$/, '')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -11,6 +11,27 @@ import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
|
||||
import { AssetKindKey } from './types'
|
||||
import type { FormDropdownMenuItemProps } from './types'
|
||||
|
||||
const mockFindServerPreviewUrl = vi.hoisted(() => vi.fn())
|
||||
const mockIsAssetPreviewSupported = vi.hoisted(() => vi.fn(() => true))
|
||||
const intersectionCallbacks = vi.hoisted(
|
||||
() => [] as Array<(entries: Array<{ isIntersecting: boolean }>) => void>
|
||||
)
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name),
|
||||
isAssetPreviewSupported: () => mockIsAssetPreviewSupported()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useIntersectionObserver: (
|
||||
_ref: unknown,
|
||||
cb: (entries: Array<{ isIntersecting: boolean }>) => void
|
||||
) => {
|
||||
intersectionCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
const selectedLabel = 'Selected'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -48,7 +69,23 @@ function renderItem(
|
||||
})
|
||||
}
|
||||
|
||||
function fireIntersection(isIntersecting = true) {
|
||||
for (const cb of intersectionCallbacks) {
|
||||
cb([{ isIntersecting }])
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('FormDropdownMenuItem', () => {
|
||||
beforeEach(() => {
|
||||
intersectionCallbacks.length = 0
|
||||
mockFindServerPreviewUrl.mockReset()
|
||||
mockIsAssetPreviewSupported.mockReset().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Label and name', () => {
|
||||
it('renders name when no label is provided', () => {
|
||||
renderItem({ name: 'alpha' })
|
||||
@@ -89,6 +126,85 @@ describe('FormDropdownMenuItem', () => {
|
||||
)
|
||||
expect(screen.queryByRole('img', { name: 'item_name' })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not look up mesh preview when kind is image', async () => {
|
||||
renderItem({ previewUrl: '/preview.png' }, { assetKind: 'image' })
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mesh thumbnail resolution', () => {
|
||||
it('shows 3D placeholder icon when mesh preview is unresolved', () => {
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
expect(screen.getByTestId('dropdown-item-mesh-placeholder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('looks up preview with basename after intersection fires', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue('/api/view?preview=1')
|
||||
renderItem({ name: '3d/sub/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('model.glb')
|
||||
})
|
||||
|
||||
it('strips [output] suffix before taking basename', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue(null)
|
||||
renderItem({ name: 'mesh/scene.glb [output]' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('scene.glb')
|
||||
})
|
||||
|
||||
it('renders resolved URL in img once findServerPreviewUrl returns', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue('/api/preview/resolved.png')
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
const img = (await screen.findByAltText(
|
||||
'3d/model.glb'
|
||||
)) as HTMLImageElement
|
||||
expect(img.getAttribute('src')).toBe('/api/preview/resolved.png')
|
||||
})
|
||||
|
||||
it('skips lookup when asset preview is unsupported', async () => {
|
||||
mockIsAssetPreviewSupported.mockReturnValue(false)
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only looks up once for repeated intersection events', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue(null)
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
fireIntersection(true)
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not look up when not yet intersecting', async () => {
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(false)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores the previewUrl prop for mesh kind', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue(null)
|
||||
renderItem(
|
||||
{
|
||||
name: '3d/model.glb',
|
||||
previewUrl: '/api/view?filename=model.glb&type=input'
|
||||
},
|
||||
{ assetKind: 'mesh' }
|
||||
)
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(screen.queryByAltText('3d/model.glb')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Events', () => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
|
||||
import { AssetKindKey } from './types'
|
||||
import type { FormDropdownMenuItemProps } from './types'
|
||||
|
||||
@@ -21,6 +27,42 @@ const actualDimensions = ref<string | null>(null)
|
||||
const assetKind = inject(AssetKindKey)
|
||||
|
||||
const isVideo = computed(() => assetKind?.value === 'video')
|
||||
const isMesh = computed(() => assetKind?.value === 'mesh')
|
||||
|
||||
const mediaContainerRef = ref<HTMLElement>()
|
||||
const resolvedMeshPreview = ref<string | null>(null)
|
||||
const meshPreviewAttempted = ref(false)
|
||||
|
||||
function toLookupName(name: string): string {
|
||||
const stripped = name.replace(/ \[output\]$/, '')
|
||||
const slash = stripped.lastIndexOf('/')
|
||||
return slash === -1 ? stripped : stripped.substring(slash + 1)
|
||||
}
|
||||
|
||||
async function resolveMeshPreview() {
|
||||
if (!isAssetPreviewSupported()) return
|
||||
const url = await findServerPreviewUrl(toLookupName(props.name))
|
||||
if (url) resolvedMeshPreview.value = url
|
||||
}
|
||||
|
||||
useIntersectionObserver(mediaContainerRef, ([entry]) => {
|
||||
if (!entry?.isIntersecting) return
|
||||
if (!isMesh.value || meshPreviewAttempted.value) return
|
||||
meshPreviewAttempted.value = true
|
||||
void resolveMeshPreview()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.name,
|
||||
() => {
|
||||
meshPreviewAttempted.value = false
|
||||
resolvedMeshPreview.value = null
|
||||
}
|
||||
)
|
||||
|
||||
const displayedPreviewUrl = computed(() =>
|
||||
isMesh.value ? resolvedMeshPreview.value : props.previewUrl
|
||||
)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.index)
|
||||
@@ -68,6 +110,7 @@ function handleVideoLoad(event: Event) {
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-if="layout !== 'list-small'"
|
||||
ref="mediaContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'relative',
|
||||
@@ -106,15 +149,23 @@ function handleVideoLoad(event: Event) {
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
v-else-if="displayedPreviewUrl"
|
||||
:src="displayedPreviewUrl"
|
||||
:alt="name"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isMesh"
|
||||
data-testid="dropdown-item-mesh-placeholder"
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background"
|
||||
>
|
||||
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
data-testid="dropdown-item-media-placeholder"
|
||||
class="size-full bg-linear-to-tr from-blue-400 via-teal-500 to-green-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -241,6 +241,59 @@ describe('useWidgetSelectItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mesh preview URL handling', () => {
|
||||
it('leaves input preview_url empty for mesh kind', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['3d/model.glb', 'other.fbx'],
|
||||
modelValue: ref('3d/model.glb'),
|
||||
assetKind: () => 'mesh'
|
||||
})
|
||||
)
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
expect(dropdownItems.value[0].preview_url).toBe('')
|
||||
expect(dropdownItems.value[1].preview_url).toBe('')
|
||||
})
|
||||
|
||||
it('leaves output preview_url empty for mesh kind even when asset has one', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-mesh-1',
|
||||
name: 'scene.glb',
|
||||
preview_url: '/api/view?filename=scene.glb&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined),
|
||||
assetKind: () => 'mesh'
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].preview_url).toBe('')
|
||||
})
|
||||
|
||||
it('still uses getMediaUrl for image kind inputs', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png'],
|
||||
modelValue: ref('img_001.png'),
|
||||
assetKind: () => 'image'
|
||||
})
|
||||
)
|
||||
expect(dropdownItems.value[0].preview_url).toContain(
|
||||
'filename=img_001.png'
|
||||
)
|
||||
expect(dropdownItems.value[0].preview_url).toContain('type=input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud asset mode', () => {
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
|
||||
@@ -50,7 +50,7 @@ function getMediaUrl(
|
||||
type: 'input' | 'output',
|
||||
assetKind: AssetKind | undefined
|
||||
): string {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
|
||||
if (!['image', 'video', 'audio'].includes(assetKind ?? '')) return ''
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
@@ -183,7 +183,9 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
items.push({
|
||||
id: `output-${asset.id}`,
|
||||
preview_url:
|
||||
asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
kind === 'mesh'
|
||||
? ''
|
||||
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath, labelFn)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isBackendReachable } from '@/platform/connectionPanel/backendReachable'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -29,13 +30,17 @@ const isFileProtocol = window.location.protocol === 'file:'
|
||||
* Determine base path for the router.
|
||||
* - Electron: always root
|
||||
* - Cloud: use Vite's BASE_URL (configured at build time)
|
||||
* - Standard web (including reverse proxy subpaths): use window.location.pathname
|
||||
* to support deployments like http://mysite.com/ComfyUI/
|
||||
* - Standard web: a deploy directory pathname ends with `/`
|
||||
* (e.g. `/ComfyUI/`) — use it as base to support reverse-proxy subpaths.
|
||||
* A SPA route pathname does not end with `/` (e.g. `/connect`) — fall back
|
||||
* to BASE_URL so the route doesn't get appended to itself.
|
||||
*/
|
||||
function getBasePath(): string {
|
||||
if (isDesktop) return '/'
|
||||
if (isCloud) return import.meta.env?.BASE_URL || '/'
|
||||
return window.location.pathname
|
||||
const pathname = window.location.pathname
|
||||
if (pathname.endsWith('/')) return pathname
|
||||
return import.meta.env?.BASE_URL || '/'
|
||||
}
|
||||
|
||||
const basePath = getBasePath()
|
||||
@@ -66,6 +71,12 @@ const router = createRouter({
|
||||
name: 'GraphView',
|
||||
component: () => import('@/views/GraphView.vue'),
|
||||
beforeEnter: async (_to, _from, next) => {
|
||||
// Redirect to /connect when no ComfyUI backend is reachable
|
||||
// (e.g. static deployments like Cloudflare Pages preview)
|
||||
if (!(await isBackendReachable())) {
|
||||
return next('/connect')
|
||||
}
|
||||
|
||||
// Then check user store
|
||||
const userStore = useUserStore()
|
||||
await userStore.initialize()
|
||||
@@ -82,6 +93,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/UserSelectView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/connect',
|
||||
name: 'ConnectionPanel',
|
||||
component: () => import('@/views/ConnectionPanelView.vue')
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@ const zSystemStats = z.object({
|
||||
pytorch_version: z.string(),
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
comfy_api_base: z.string().optional(),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number(),
|
||||
// Cloud-specific fields
|
||||
|
||||
@@ -367,27 +367,52 @@ export class ComfyApi extends EventTarget {
|
||||
*/
|
||||
apiKey?: string
|
||||
|
||||
/**
|
||||
* The origin (protocol + host) for the backend, when overridden via the
|
||||
* preview connection panel. Empty string means use same-origin.
|
||||
*/
|
||||
private remoteOrigin = ''
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.user = ''
|
||||
this.api_host = location.host
|
||||
this.api_base = isCloud
|
||||
? ''
|
||||
: location.pathname.split('/').slice(0, -1).join('/')
|
||||
|
||||
const remoteBackend = localStorage.getItem('comfyui-preview-backend-url')
|
||||
let parsedRemote: URL | null = null
|
||||
if (remoteBackend) {
|
||||
try {
|
||||
parsedRemote = new URL(remoteBackend)
|
||||
} catch {
|
||||
// Corrupt value would crash the app at startup; drop it and fall back.
|
||||
localStorage.removeItem('comfyui-preview-backend-url')
|
||||
}
|
||||
}
|
||||
if (parsedRemote) {
|
||||
this.remoteOrigin = parsedRemote.origin
|
||||
this.api_host = parsedRemote.host
|
||||
this.api_base = parsedRemote.pathname.replace(/\/+$/, '')
|
||||
} else {
|
||||
this.api_host = location.host
|
||||
this.api_base = isCloud
|
||||
? ''
|
||||
: location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
this.initialClientId = sessionStorage.getItem('clientId')
|
||||
}
|
||||
|
||||
internalURL(route: string): string {
|
||||
return this.api_base + '/internal' + route
|
||||
return this.remoteOrigin + this.api_base + '/internal' + route
|
||||
}
|
||||
|
||||
apiURL(route: string): string {
|
||||
if (route.startsWith('/api')) return this.api_base + route
|
||||
return this.api_base + '/api' + route
|
||||
if (route.startsWith('/api'))
|
||||
return this.remoteOrigin + this.api_base + route
|
||||
return this.remoteOrigin + this.api_base + '/api' + route
|
||||
}
|
||||
|
||||
fileURL(route: string): string {
|
||||
return this.api_base + route
|
||||
return this.remoteOrigin + this.api_base + route
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,8 +603,14 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const baseUrl = `${protocol}://${this.api_host}${this.api_base}/ws`
|
||||
// Derive WebSocket protocol from remote backend if set, else from page
|
||||
let wsProtocol: string
|
||||
if (this.remoteOrigin) {
|
||||
wsProtocol = this.remoteOrigin.startsWith('https:') ? 'wss' : 'ws'
|
||||
} else {
|
||||
wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
}
|
||||
const baseUrl = `${wsProtocol}://${this.api_host}${this.api_base}/ws`
|
||||
const query = params.toString()
|
||||
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl
|
||||
|
||||
|
||||
@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
|
||||
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
|
||||
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
|
||||
],
|
||||
properties: {},
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'v1-5-pruned-emaonly-fp16.safetensors',
|
||||
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
|
||||
}
|
||||
],
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface TopbarBadge {
|
||||
* Optional tooltip text to show on hover
|
||||
*/
|
||||
tooltip?: string
|
||||
/**
|
||||
* Optional links rendered as clickable anchors inside the popover.
|
||||
* External URLs (starting with "http") open in a new tab; internal
|
||||
* paths (e.g. "/connect") navigate within the SPA.
|
||||
*/
|
||||
popoverLinks?: Array<{ label: string; url: string }>
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
286
src/views/ConnectionPanelView.test.ts
Normal file
286
src/views/ConnectionPanelView.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ConnectionPanelView from './ConnectionPanelView.vue'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({ changeTheme: vi.fn() })),
|
||||
isNativeWindow: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: false,
|
||||
isCloud: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) delete store[key]
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
||||
_store: store
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function renderPanel() {
|
||||
return render(ConnectionPanelView, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ConnectionPanelView', () => {
|
||||
beforeEach(() => {
|
||||
mockLocalStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the backend URL input with default value', () => {
|
||||
renderPanel()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('loads backend URL from localStorage', () => {
|
||||
mockLocalStorage.setItem(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://192.168.1.100:8188'
|
||||
)
|
||||
renderPanel()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://192.168.1.100:8188'
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows test button', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByRole('button', { name: /test/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the comfy-cli install command', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText('pip install comfy-cli')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the comfy launch command', () => {
|
||||
renderPanel()
|
||||
expect(
|
||||
screen.getByText(
|
||||
`comfy launch -- --enable-cors-header="${window.location.origin}"`
|
||||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the local network access section', () => {
|
||||
renderPanel()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: /local/i })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves URL to localStorage on test', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
await user.clear(input)
|
||||
await user.type(input, 'http://10.0.0.1:8188')
|
||||
|
||||
const testButton = screen.getByRole('button', { name: /test/i })
|
||||
await user.click(testButton)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://10.0.0.1:8188'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows red HTTP indicator when fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
// Stub WebSocket to never open so wsStatus also resolves to false
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'error') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// i18n in tests is empty so the status text falls back to the key
|
||||
expect(screen.getByText(/connectionPanel\.error/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a URL without protocol by prepending http://', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
await user.clear(input)
|
||||
await user.type(input, '192.168.1.50:8188')
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://192.168.1.50:8188'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('links to staging platform when backend uses staging cloud base', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
system: {
|
||||
argv: [
|
||||
'main.py',
|
||||
'--comfy-api-base=https://stagingapi.comfy.org'
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const link = screen.getByRole('link', {
|
||||
name: 'connectionPanel.getApiKeyLink'
|
||||
})
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://stagingplatform.comfy.org/profile/api-keys'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('parses backend cloud API base from system_stats argv', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
system: {
|
||||
argv: [
|
||||
'main.py',
|
||||
'--enable-cors-header=*',
|
||||
'--comfy-api-base',
|
||||
'https://stagingapi.comfy.org'
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('https://stagingapi.comfy.org')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ system: { argv: [] } })
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// i18n in tests is empty so the button label falls back to the key
|
||||
expect(screen.getByText('connectionPanel.connectAndGo')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
598
src/views/ConnectionPanelView.vue
Normal file
598
src/views/ConnectionPanelView.vue
Normal file
@@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<main
|
||||
class="relative my-8 flex w-full max-w-lg flex-col gap-6 rounded-lg bg-(--comfy-menu-bg) p-8 shadow-lg"
|
||||
>
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-xl font-semibold text-neutral-100">
|
||||
{{ t('connectionPanel.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-neutral-400">
|
||||
{{ t('connectionPanel.subtitle') }}
|
||||
</p>
|
||||
<aside
|
||||
v-if="prNumber"
|
||||
class="mt-1 flex flex-col gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{ t('connectionPanel.previewWarningTitle') }}
|
||||
</p>
|
||||
<p class="text-amber-200/85">
|
||||
{{ t('connectionPanel.previewWarningBody') }}
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="connectionPanel.previewProvenance"
|
||||
tag="p"
|
||||
class="text-amber-200/85"
|
||||
>
|
||||
<template #pr>
|
||||
<a
|
||||
:href="prUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
>#{{ prNumber }}</a
|
||||
>
|
||||
</template>
|
||||
<template #commit>
|
||||
<a
|
||||
:href="commitUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
><code>{{ commitShort }}</code></a
|
||||
>
|
||||
</template>
|
||||
<template #author>
|
||||
<a
|
||||
v-if="prAuthor"
|
||||
:href="authorUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
>@{{ prAuthor }}</a
|
||||
>
|
||||
<span v-else>{{
|
||||
t('connectionPanel.previewUnknownAuthor')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p class="font-medium text-amber-100">
|
||||
{{ t('connectionPanel.previewTrustWarning') }}
|
||||
</p>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<!-- Backend URL input -->
|
||||
<section class="flex flex-col gap-2">
|
||||
<label for="backend-url" class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.backendUrl') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="backend-url"
|
||||
v-model="backendUrl"
|
||||
type="url"
|
||||
:placeholder="DEFAULT_BACKEND_URL"
|
||||
class="flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-neutral-800 px-4 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
|
||||
@keyup.enter="testConnection"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isTesting"
|
||||
:disabled="isTesting"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ t('connectionPanel.test') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connection status -->
|
||||
<section
|
||||
v-if="httpStatus !== null || wsStatus !== null"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex flex-col gap-2 rounded-md bg-neutral-800/50 p-3"
|
||||
>
|
||||
<h2
|
||||
class="text-xs font-medium tracking-wide text-neutral-400 uppercase"
|
||||
>
|
||||
{{ t('connectionPanel.status') }}
|
||||
</h2>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-block size-2 rounded-full',
|
||||
httpStatus === true && 'bg-green-500',
|
||||
httpStatus === false && 'bg-red-500',
|
||||
httpStatus === null && 'bg-neutral-600'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ t('connectionPanel.http') }}
|
||||
{{ httpStatus === true ? '✓' : httpStatus === false ? '✗' : '—' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-block size-2 rounded-full',
|
||||
wsStatus === true && 'bg-green-500',
|
||||
wsStatus === false && 'bg-red-500',
|
||||
wsStatus === null && 'bg-neutral-600'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ t('connectionPanel.ws') }}
|
||||
{{ wsStatus === true ? '✓' : wsStatus === false ? '✗' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="connectionError" class="text-xs text-red-400">
|
||||
{{ connectionError }}
|
||||
</p>
|
||||
<p
|
||||
v-if="httpStatus === true && wsStatus === true"
|
||||
class="text-xs text-green-400"
|
||||
>
|
||||
{{ t('connectionPanel.connected') }}
|
||||
</p>
|
||||
|
||||
<!-- Backend cloud-API base + API key -->
|
||||
<div
|
||||
v-if="backendCloudBase"
|
||||
class="flex flex-col gap-3 border-t border-neutral-700 pt-2"
|
||||
>
|
||||
<p class="text-xs text-neutral-400">
|
||||
<span class="text-neutral-500"
|
||||
>{{ t('connectionPanel.backendCloud') }}
|
||||
</span>
|
||||
<code
|
||||
class="ml-1 rounded-sm bg-neutral-900 px-1 py-0.5 text-neutral-200"
|
||||
>{{ backendCloudBase }}</code
|
||||
>
|
||||
</p>
|
||||
<p v-if="cloudMismatch" class="text-xs text-amber-400">
|
||||
{{
|
||||
t('connectionPanel.cloudMismatch', {
|
||||
frontend: frontendCloudBase
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- API key input — hidden when --disable-api-nodes -->
|
||||
<div v-if="!isApiNodeDisabled" class="flex flex-col gap-1.5">
|
||||
<label for="api-key" class="text-xs font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.apiKey') }}
|
||||
<span class="ml-1 font-normal text-neutral-500">{{
|
||||
t('connectionPanel.apiKeyOptional')
|
||||
}}</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="api-key"
|
||||
v-model="apiKeyInput"
|
||||
type="password"
|
||||
:placeholder="t('connectionPanel.apiKeyPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
class="flex h-8 w-full min-w-0 appearance-none rounded-md border-none bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
|
||||
@keyup.enter="testApiKey"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:loading="isTestingApiKey"
|
||||
:disabled="isTestingApiKey || !apiKeyInput.trim()"
|
||||
@click="testApiKey"
|
||||
>
|
||||
{{ t('connectionPanel.test') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="apiKeyStatus === 'ok'" class="text-xs text-green-400">
|
||||
{{ t('connectionPanel.apiKeyTestOk') }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="apiKeyStatus === 'error'"
|
||||
class="text-xs text-red-400"
|
||||
>
|
||||
{{ t('connectionPanel.apiKeyTestError') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.apiKeyHint') }}
|
||||
<a
|
||||
v-if="apiKeyPageUrl"
|
||||
:href="apiKeyPageUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="ml-1 text-neutral-400 underline decoration-dotted hover:text-neutral-200"
|
||||
>
|
||||
{{ t('connectionPanel.getApiKeyLink') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.apiKeyDisabledNotice') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connect & Go button -->
|
||||
<Button
|
||||
v-if="httpStatus === true"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="mt-2 w-full"
|
||||
@click="connectAndGo"
|
||||
>
|
||||
{{ t('connectionPanel.connectAndGo') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<!-- Quick Start with Comfy CLI -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.quickStart') }}
|
||||
</h2>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.quickStartDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step1InstallUv') }}
|
||||
</span>
|
||||
<CopyCodeBlock
|
||||
text="curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
/>
|
||||
<CopyCodeBlock
|
||||
text='powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
|
||||
/>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.uvNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step2InstallComfyui') }}
|
||||
</span>
|
||||
<CopyCodeBlock
|
||||
text="uv pip install comfy-cli --system && comfy install"
|
||||
/>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.managerIncludedNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step3Launch') }}
|
||||
</span>
|
||||
<CopyCodeBlock :text="launchCmd" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.corsOriginNote') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.corsNote') }}
|
||||
</p>
|
||||
|
||||
<aside
|
||||
class="flex flex-col gap-1 rounded-md border border-neutral-700 bg-neutral-800/50 p-3"
|
||||
>
|
||||
<h3 class="text-xs font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.managerTitle') }}
|
||||
</h3>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.managerDescription') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Comfy-Org/ComfyUI-Manager"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-xs text-neutral-300 underline hover:text-neutral-100"
|
||||
>
|
||||
{{ t('connectionPanel.managerLearnMore') }}
|
||||
</a>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- Alternative: manual python / pip -->
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer text-sm font-medium text-neutral-400 hover:text-neutral-300"
|
||||
>
|
||||
{{ t('connectionPanel.altManualSetup') }}
|
||||
</summary>
|
||||
<div class="mt-2 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.altPipDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock text="pip install comfy-cli" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.altPipNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.altManagerDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock
|
||||
text="git clone https://github.com/Comfy-Org/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.guideDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock :text="pythonMainCmd" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Local network access -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.localAccess') }}
|
||||
</h2>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.localAccessDescription') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.localAccessListenDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock :text="launchListenCmd" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.localAccessListenNote') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-between border-t border-neutral-700 pt-4 text-xs text-neutral-500"
|
||||
>
|
||||
<span
|
||||
:title="buildTooltip"
|
||||
class="cursor-help underline decoration-dotted"
|
||||
>
|
||||
{{ buildLabel }}
|
||||
</span>
|
||||
<a
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{{ t('connectionPanel.source') }}
|
||||
</a>
|
||||
</footer>
|
||||
</main>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyCodeBlock from '@/components/connection/CopyCodeBlock.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
getComfyApiBaseUrl,
|
||||
getPlatformBaseUrlForApiBase
|
||||
} from '@/config/comfyApi'
|
||||
import { resolveBackendCloudBase } from '@/platform/connectionPanel/resolveBackendCloudBase'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
type SystemStats = {
|
||||
system?: { argv?: string[]; comfy_api_base?: string }
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8188'
|
||||
const STORAGE_KEY = 'comfyui-preview-backend-url'
|
||||
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
const corsOrigin = window.location.origin
|
||||
|
||||
const backendUrl = ref(localStorage.getItem(STORAGE_KEY) || DEFAULT_BACKEND_URL)
|
||||
const API_KEY_STORAGE_KEY = 'comfy_api_key'
|
||||
const apiKeyInput = ref(localStorage.getItem(API_KEY_STORAGE_KEY) ?? '')
|
||||
|
||||
const launchCmd = `comfy launch -- --enable-cors-header="${corsOrigin}"`
|
||||
const launchListenCmd = `comfy launch -- --listen --enable-cors-header="${corsOrigin}"`
|
||||
const pythonMainCmd = `python main.py --enable-cors-header="${corsOrigin}"`
|
||||
|
||||
const isTesting = ref(false)
|
||||
const httpStatus = ref<boolean | null>(null)
|
||||
const wsStatus = ref<boolean | null>(null)
|
||||
const connectionError = ref('')
|
||||
const backendCloudBase = ref<string | null>(null)
|
||||
const isApiNodeDisabled = ref(false)
|
||||
|
||||
const isTestingApiKey = ref(false)
|
||||
const apiKeyStatus = ref<'idle' | 'ok' | 'error'>('idle')
|
||||
const frontendCloudBase = stripTrailingSlash(getComfyApiBaseUrl())
|
||||
const cloudMismatch = computed(
|
||||
() =>
|
||||
backendCloudBase.value !== null &&
|
||||
backendCloudBase.value !== frontendCloudBase
|
||||
)
|
||||
const apiKeyPageUrl = computed(() => {
|
||||
if (!backendCloudBase.value) return null
|
||||
const platform = getPlatformBaseUrlForApiBase(backendCloudBase.value)
|
||||
return platform ? `${platform}/profile/api-keys` : null
|
||||
})
|
||||
|
||||
function normalizeUrl(raw: string): string {
|
||||
let url = raw.trim()
|
||||
if (!url) url = DEFAULT_BACKEND_URL
|
||||
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function fetchSystemStats(base: string): Promise<SystemStats | null> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const res = await fetch(`${base}/api/system_stats`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as SystemStats
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function testWs(base: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const wsUrl = new URL(base)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsUrl.pathname = '/ws'
|
||||
wsUrl.search = ''
|
||||
wsUrl.hash = ''
|
||||
const ws = new WebSocket(wsUrl.toString())
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve(true)
|
||||
})
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
isTesting.value = true
|
||||
httpStatus.value = null
|
||||
wsStatus.value = null
|
||||
connectionError.value = ''
|
||||
backendCloudBase.value = null
|
||||
|
||||
const base = normalizeUrl(backendUrl.value)
|
||||
backendUrl.value = base
|
||||
localStorage.setItem(STORAGE_KEY, base)
|
||||
|
||||
try {
|
||||
const [stats, ws] = await Promise.all([
|
||||
fetchSystemStats(base),
|
||||
testWs(base)
|
||||
])
|
||||
httpStatus.value = stats !== null
|
||||
wsStatus.value = ws
|
||||
backendCloudBase.value = stats
|
||||
? resolveBackendCloudBase(stats.system)
|
||||
: null
|
||||
isApiNodeDisabled.value =
|
||||
stats?.system?.argv?.includes('--disable-api-nodes') ?? false
|
||||
|
||||
if (stats === null && !ws) {
|
||||
connectionError.value = t('connectionPanel.errorUnreachable')
|
||||
} else if (stats === null) {
|
||||
connectionError.value = t('connectionPanel.errorHttpFailed')
|
||||
} else if (!ws) {
|
||||
connectionError.value = t('connectionPanel.errorWsFailed')
|
||||
}
|
||||
} catch {
|
||||
httpStatus.value = false
|
||||
wsStatus.value = false
|
||||
connectionError.value = t('connectionPanel.errorUnreachable')
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiKey() {
|
||||
const key = apiKeyInput.value.trim()
|
||||
if (!key) return
|
||||
isTestingApiKey.value = true
|
||||
apiKeyStatus.value = 'idle'
|
||||
const base = backendCloudBase.value ?? frontendCloudBase
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
const res = await fetch(`${base}/customers`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
apiKeyStatus.value = res.ok ? 'ok' : 'error'
|
||||
} catch {
|
||||
apiKeyStatus.value = 'error'
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
isTestingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function connectAndGo() {
|
||||
const base = normalizeUrl(backendUrl.value)
|
||||
localStorage.setItem(STORAGE_KEY, base)
|
||||
const trimmedKey = apiKeyInput.value.trim()
|
||||
if (trimmedKey) {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey)
|
||||
}
|
||||
// Full page reload so ComfyApi constructor picks up the new backend URL
|
||||
window.location.href = import.meta.env.BASE_URL || '/'
|
||||
}
|
||||
|
||||
const version = __COMFYUI_FRONTEND_VERSION__
|
||||
const commit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const branch = __CI_BRANCH__
|
||||
const prNumber = __CI_PR_NUMBER__
|
||||
const prAuthor = __CI_PR_AUTHOR__
|
||||
const runId = __CI_RUN_ID__
|
||||
const jobId = __CI_JOB_ID__
|
||||
|
||||
const commitShort = commit ? commit.slice(0, 8) : ''
|
||||
const prUrl = prNumber ? `${REPO}/pull/${prNumber}` : REPO
|
||||
const commitUrl = commit ? `${REPO}/commit/${commit}` : REPO
|
||||
const authorUrl = prAuthor ? `https://github.com/${prAuthor}` : ''
|
||||
|
||||
const buildLabel = computed(() => {
|
||||
if (prNumber) return t('connectionPanel.buildPr', { prNumber })
|
||||
if (branch) return branch
|
||||
return t('connectionPanel.buildVersion', { version })
|
||||
})
|
||||
|
||||
const buildTooltip = computed(() => {
|
||||
const parts = [t('connectionPanel.tooltipVersion', { version })]
|
||||
if (commit)
|
||||
parts.push(
|
||||
t('connectionPanel.tooltipCommit', { commit: commit.slice(0, 8) })
|
||||
)
|
||||
if (branch) parts.push(t('connectionPanel.tooltipBranch', { branch }))
|
||||
if (runId) parts.push(t('connectionPanel.tooltipRunId', { runId }))
|
||||
if (jobId) parts.push(t('connectionPanel.tooltipJobId', { jobId }))
|
||||
return parts.join('\n')
|
||||
})
|
||||
|
||||
const repoUrl = computed(() => {
|
||||
if (prNumber) return `${REPO}/pull/${prNumber}`
|
||||
if (branch) return `${REPO}/tree/${branch}`
|
||||
return REPO
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
})
|
||||
</script>
|
||||
@@ -13,7 +13,7 @@
|
||||
ref="topMenuRef"
|
||||
class="app-drag h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div class="flex w-full grow items-center justify-center overflow-auto">
|
||||
<div class="grid w-full grow place-items-center overflow-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
7
src/vite-env.d.ts
vendored
7
src/vite-env.d.ts
vendored
@@ -17,6 +17,13 @@ declare global {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
}
|
||||
|
||||
const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
const __CI_BRANCH__: string
|
||||
const __CI_PR_NUMBER__: string
|
||||
const __CI_PR_AUTHOR__: string
|
||||
const __CI_RUN_ID__: string
|
||||
const __CI_JOB_ID__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
VITE_APP_VERSION?: string
|
||||
}
|
||||
|
||||
@@ -626,7 +626,12 @@ export default defineConfig({
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
|
||||
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
|
||||
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY),
|
||||
__CI_BRANCH__: JSON.stringify(process.env.CI_BRANCH || ''),
|
||||
__CI_PR_NUMBER__: JSON.stringify(process.env.CI_PR_NUMBER || ''),
|
||||
__CI_PR_AUTHOR__: JSON.stringify(process.env.CI_PR_AUTHOR || ''),
|
||||
__CI_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
|
||||
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user