mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
55 Commits
test/cov-S
...
rizumu/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c499cf1de0 | ||
|
|
23b54494cb | ||
|
|
bea72410fd | ||
|
|
cbc479d8b4 | ||
|
|
a6b3aa1667 | ||
|
|
d682b3c7da | ||
|
|
65b8a5652c | ||
|
|
5a598ef2e1 | ||
|
|
2c772077e0 | ||
|
|
00c294297e | ||
|
|
983789753e | ||
|
|
91ed6a37e2 | ||
|
|
15c5a298a6 | ||
|
|
65e27b5cdf | ||
|
|
dd16e7a9ea | ||
|
|
63d0e3ae5d | ||
|
|
71ca582325 | ||
|
|
9ed7a7bd87 | ||
|
|
3e62033f09 | ||
|
|
78630f5485 | ||
|
|
55c5fce522 | ||
|
|
4b5c15fc7d | ||
|
|
b36242475c | ||
|
|
2f4116fa81 | ||
|
|
d83c84aa85 | ||
|
|
c1c3fba1ac | ||
|
|
35bfe509b3 | ||
|
|
5d98e11ba1 | ||
|
|
60c7471818 | ||
|
|
0ac4c3d6c5 | ||
|
|
feafdc0b4a | ||
|
|
2fea0aa538 | ||
|
|
a1ba567dbc | ||
|
|
d2e30645fe | ||
|
|
fc61b19cb9 | ||
|
|
8a5a8f0a6e | ||
|
|
0638e8e993 | ||
|
|
07ce7123c8 | ||
|
|
799ffcf4b6 | ||
|
|
1020e8cf32 | ||
|
|
b157182a20 | ||
|
|
2bfe3443ab | ||
|
|
4c35add5bc | ||
|
|
e4d9b1c214 | ||
|
|
7e5143d2f1 | ||
|
|
bf606a209e | ||
|
|
3159921280 | ||
|
|
20198f1465 | ||
|
|
b0e6942e92 | ||
|
|
bf906c26c6 | ||
|
|
d6aabe7d20 | ||
|
|
56b0f6b822 | ||
|
|
5aa18edeb0 | ||
|
|
c9bf31e13b | ||
|
|
e067c4434d |
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: Resolve PR from workflow_run
|
||||
description: >
|
||||
Resolves the PR number from a workflow_run event using pull_requests[0]
|
||||
with a listPullRequestsAssociatedWithCommit fallback.
|
||||
Skips closed/merged PRs and stale runs (head SHA mismatch).
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: GitHub token for API calls
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
outputs:
|
||||
skip:
|
||||
description: "'true' when no open PR was found or the run is stale"
|
||||
value: ${{ steps.resolve.outputs.skip }}
|
||||
number:
|
||||
description: The PR number (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.number }}
|
||||
base:
|
||||
description: The PR base branch (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.base }}
|
||||
head-sha:
|
||||
description: The PR head SHA (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.head-sha }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
// Fork PRs: pull_requests is empty and commit SHA may not be in
|
||||
// the base repo graph. Fall back to pulls.list with head filter.
|
||||
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
|
||||
per_page: 1,
|
||||
});
|
||||
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.state !== 'open') {
|
||||
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(
|
||||
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
|
||||
);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
15
.github/workflows/ci-perf-report.yaml
vendored
15
.github/workflows/ci-perf-report.yaml
vendored
@@ -58,21 +58,6 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Save PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mkdir -p temp/perf-meta
|
||||
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
|
||||
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
|
||||
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: perf-meta
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Save perf baseline to perf-data branch
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
|
||||
continue-on-error: true
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
7
.github/workflows/ci-size-data.yaml
vendored
@@ -32,13 +32,6 @@ jobs:
|
||||
- name: Collect size data
|
||||
run: node scripts/size-collect.js
|
||||
|
||||
- name: Save PR metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
echo ${{ github.event.number }} > ./temp/size/number.txt
|
||||
echo ${{ github.base_ref }} > ./temp/size/base.txt
|
||||
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
|
||||
|
||||
- name: Upload size data
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
|
||||
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: reports
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -85,6 +72,6 @@ jobs:
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
workflows: ['CI: Tests Storybook']
|
||||
types: [requested, completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -82,6 +69,6 @@ jobs:
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -18,6 +18,12 @@ on:
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
VERCEL_SCOPE: comfyui
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
@@ -25,6 +31,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -32,28 +40,83 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
id: head-commit
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: context.payload.pull_request.head.sha,
|
||||
})
|
||||
const author = data.author?.login || data.commit.author?.name || ''
|
||||
const message = (data.commit.message || '').split('\n', 1)[0]
|
||||
core.setOutput('author', author)
|
||||
core.setOutput('message', message)
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
env:
|
||||
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
|
||||
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
|
||||
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
|
||||
GIT_PR_ID: ${{ github.event.pull_request.number }}
|
||||
GIT_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
URL=$(vercel deploy --prebuilt \
|
||||
--meta githubCommitRef="$GIT_COMMIT_REF" \
|
||||
--meta githubCommitSha="$GIT_COMMIT_SHA" \
|
||||
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
|
||||
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
|
||||
--meta githubPrId="$GIT_PR_ID" \
|
||||
--meta githubRepo="$GIT_REPO")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
- name: Alias deployment to stable PR hostname
|
||||
id: alias-set
|
||||
continue-on-error: true
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
run: |
|
||||
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
|
||||
|
||||
- name: Publish preview outputs
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
STABLE_URL="https://$ALIAS_HOST"
|
||||
else
|
||||
STABLE_URL="$DEPLOY_URL"
|
||||
fi
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
|
||||
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
|
||||
{
|
||||
echo "**Preview:** $STABLE_URL"
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
echo "**This commit:** $DEPLOY_URL"
|
||||
else
|
||||
echo "_Stable alias update failed — URL reflects this commit only._"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -71,19 +134,24 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
URL=$(vercel deploy --prebuilt --prod)
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
|
||||
37
.github/workflows/pr-report.yaml
vendored
37
.github/workflows/pr-report.yaml
vendored
@@ -30,42 +30,7 @@ jobs:
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the workflow_run head SHA matches the current PR head
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Find size workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
|
||||
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -12,6 +12,10 @@ permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,38 +35,24 @@ jobs:
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
|
||||
- name: Read preview URL
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: meta
|
||||
run: |
|
||||
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
env:
|
||||
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
|
||||
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
|
||||
SHORT_SHA="${HEAD_SHA:0:7}"
|
||||
cat > preview-report.md <<EOF
|
||||
**Website Preview:** $STABLE_URL
|
||||
|
||||
<sub>This commit: $UNIQUE_URL</sub>
|
||||
|
||||
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
|
||||
EOF
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
|
||||
@@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||
- `pnpm build`: Type-check then production build to `dist/`
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
{
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
135
browser_tests/assets/nodes/glsl_shader_in_subgraph.json
Normal file
135
browser_tests/assets/nodes/glsl_shader_in_subgraph.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000001",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555555",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555555",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444444",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [250, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
69
browser_tests/assets/nodes/glsl_shader_standalone.json
Normal file
69
browser_tests/assets/nodes/glsl_shader_standalone.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [200, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(v_texCoord.x, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
179
browser_tests/assets/nodes/glsl_shader_subgraph_with_float.json
Normal file
179
browser_tests/assets/nodes/glsl_shader_subgraph_with_float.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000002",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555556",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph With Float",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555556",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph With Float",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444445",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [1.0]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_bool.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_bool.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_bool0",
|
||||
"localized_name": "bools.u_bool0",
|
||||
"name": "bools.u_bool0",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform bool u_bool0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = u_bool0 ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 0.0, 1.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveBoolean",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "BOOLEAN",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "BOOLEAN",
|
||||
"name": "BOOLEAN",
|
||||
"type": "BOOLEAN",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveBoolean" },
|
||||
"widgets_values": [false]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "BOOLEAN"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_float.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_float.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [0.25]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_int.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_int.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_int0",
|
||||
"localized_name": "ints.u_int0",
|
||||
"name": "ints.u_int0",
|
||||
"shape": 7,
|
||||
"type": "INT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform int u_int0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(float(u_int0) / 100.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveInt" },
|
||||
"widgets_values": [25, "randomize"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
92
browser_tests/assets/nodes/glsl_shader_with_loadimage.json
Normal file
92
browser_tests/assets/nodes/glsl_shader_with_loadimage.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1] },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "image0",
|
||||
"localized_name": "images.image0",
|
||||
"name": "images.image0",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
@@ -179,7 +177,6 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
public readonly visibleToasts: Locator
|
||||
@@ -233,7 +230,6 @@ export class ComfyPage {
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
@@ -316,11 +312,20 @@ export class ComfyPage {
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the app to finish initializing after navigation/reload:
|
||||
* `window.app.extensionManager` is present, the PrimeVue block-UI mask is
|
||||
* hidden, and one animation frame has elapsed. Shared by `setup()` and
|
||||
* `WorkflowHelper.reloadAndWaitForApp()`.
|
||||
*/
|
||||
async waitForAppReady() {
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
// window.app => GraphCanvas ready
|
||||
// window.app.extensionManager => GraphView ready
|
||||
window.app && window.app.extensionManager
|
||||
// window.app => GraphCanvas ready
|
||||
// window.app.extensionManager => GraphView ready
|
||||
() => window.app?.extensionManager
|
||||
)
|
||||
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
|
||||
await this.nextFrame()
|
||||
@@ -499,7 +504,6 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
assetApi: async ({ page }, use) => {
|
||||
const assetApi = createAssetHelper(page)
|
||||
|
||||
await use(assetApi)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class ShortcutsTab {
|
||||
readonly essentialsTab: Locator
|
||||
readonly viewControlsTab: Locator
|
||||
@@ -16,6 +18,26 @@ class ShortcutsTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class LogsTab {
|
||||
readonly tab: Locator
|
||||
readonly terminalRoot: Locator
|
||||
readonly terminalHost: Locator
|
||||
readonly copyButton: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly loadingSpinner: Locator
|
||||
readonly xtermScreen: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.tab = page.getByRole('tab', { name: /Logs/i })
|
||||
this.terminalRoot = page.getByTestId(TestIds.terminal.root)
|
||||
this.terminalHost = page.getByTestId(TestIds.terminal.host)
|
||||
this.copyButton = page.getByTestId(TestIds.terminal.copyButton)
|
||||
this.errorMessage = page.getByTestId(TestIds.terminal.errorMessage)
|
||||
this.loadingSpinner = page.getByTestId(TestIds.terminal.loadingSpinner)
|
||||
this.xtermScreen = this.terminalHost.locator('.xterm-screen')
|
||||
}
|
||||
}
|
||||
|
||||
export class BottomPanel {
|
||||
readonly root: Locator
|
||||
readonly keyboardShortcutsButton: Locator
|
||||
@@ -23,6 +45,7 @@ export class BottomPanel {
|
||||
readonly closeButton: Locator
|
||||
readonly resizeGutter: Locator
|
||||
readonly shortcuts: ShortcutsTab
|
||||
readonly logs: LogsTab
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.locator('.bottom-panel')
|
||||
@@ -38,6 +61,15 @@ export class BottomPanel {
|
||||
'.splitter-overlay-bottom > .p-splitter-gutter'
|
||||
)
|
||||
this.shortcuts = new ShortcutsTab(page)
|
||||
this.logs = new LogsTab(page)
|
||||
}
|
||||
|
||||
async toggleLogs() {
|
||||
await this.toggleButton.click()
|
||||
await this.logs.tab.waitFor({ state: 'visible' })
|
||||
if ((await this.logs.tab.getAttribute('aria-selected')) !== 'true') {
|
||||
await this.logs.tab.click()
|
||||
}
|
||||
}
|
||||
|
||||
async resizeByDragging(deltaY: number): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class CancelSubscriptionDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly keepSubscriptionButton: Locator
|
||||
readonly confirmCancelButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
this.keepSubscriptionButton = this.root.getByRole('button', {
|
||||
name: 'Keep subscription'
|
||||
})
|
||||
this.confirmCancelButton = this.root.getByRole('button', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
}
|
||||
|
||||
async open(cancelAt?: string) {
|
||||
await this.page.evaluate((date) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showCancelSubscriptionDialog(date)
|
||||
}, cancelAt)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
54
browser_tests/fixtures/components/TopUpCreditsDialog.ts
Normal file
54
browser_tests/fixtures/components/TopUpCreditsDialog.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class TopUpCreditsDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly insufficientHeading: Locator
|
||||
readonly preset10: Locator
|
||||
readonly preset25: Locator
|
||||
readonly preset50: Locator
|
||||
readonly preset100: Locator
|
||||
readonly payAmountInput: Locator
|
||||
readonly pricingLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
|
||||
this.insufficientHeading = this.root.getByRole('heading', {
|
||||
name: 'Add more credits to run'
|
||||
})
|
||||
this.preset10 = this.root.getByRole('button', {
|
||||
name: '$10',
|
||||
exact: true
|
||||
})
|
||||
this.preset25 = this.root.getByRole('button', {
|
||||
name: '$25',
|
||||
exact: true
|
||||
})
|
||||
this.preset50 = this.root.getByRole('button', {
|
||||
name: '$50',
|
||||
exact: true
|
||||
})
|
||||
this.preset100 = this.root.getByRole('button', {
|
||||
name: '$100',
|
||||
exact: true
|
||||
})
|
||||
this.payAmountInput = this.root
|
||||
.getByTestId('top-up-pay-amount')
|
||||
.locator('input')
|
||||
this.pricingLink = this.root.getByRole('link', {
|
||||
name: 'View pricing details'
|
||||
})
|
||||
}
|
||||
|
||||
async open(options?: { isInsufficientCredits?: boolean }) {
|
||||
await this.page.evaluate((opts) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showTopUpCreditsDialog(opts)
|
||||
}, options)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
argv: ['main.py'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
|
||||
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { Locator, Page, Route } from '@playwright/test'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
|
||||
export type HelpMenuItemKey =
|
||||
| 'feedback'
|
||||
| 'help'
|
||||
| 'docs'
|
||||
| 'discord'
|
||||
| 'github'
|
||||
| 'manager'
|
||||
| 'update-comfyui'
|
||||
| 'more'
|
||||
|
||||
export class HelpCenterHelper {
|
||||
public readonly button: Locator
|
||||
public readonly popup: Locator
|
||||
public readonly backdrop: Locator
|
||||
public readonly whatsNewSection: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.button = page.getByTestId(TestIds.helpCenter.button)
|
||||
this.popup = page.getByTestId(TestIds.helpCenter.popup)
|
||||
this.backdrop = page.getByTestId(TestIds.helpCenter.backdrop)
|
||||
this.whatsNewSection = page.getByTestId(TestIds.dialogs.whatsNewSection)
|
||||
}
|
||||
|
||||
menuItem(key: HelpMenuItemKey): Locator {
|
||||
return this.page.getByTestId(TestIds.helpCenter.menuItem(key))
|
||||
}
|
||||
|
||||
releaseItem(version: string): Locator {
|
||||
return this.page.getByTestId(TestIds.helpCenter.releaseItem(version))
|
||||
}
|
||||
|
||||
get releaseItems(): Locator {
|
||||
return this.whatsNewSection.locator('[data-testid^="help-release-item-"]')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.button.waitFor({ state: 'visible' })
|
||||
await this.button.click()
|
||||
await this.popup.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async closeViaBackdrop(): Promise<void> {
|
||||
await this.backdrop.click()
|
||||
await this.popup.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async toggle(): Promise<void> {
|
||||
await this.button.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the Comfy release API so the help center gets a deterministic
|
||||
* list of releases. Empty array is used when `releases` is omitted.
|
||||
*/
|
||||
async mockReleases(releases: ReleaseNote[] = []): Promise<void> {
|
||||
await this.page.route('**/releases**', async (route: Route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(releases)
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept the Zendesk support URL so it never actually loads in the
|
||||
* new tab opened by the Contact Support command.
|
||||
*/
|
||||
async stubSupportPage(): Promise<void> {
|
||||
await this.page
|
||||
.context()
|
||||
.route('https://support.comfy.org/**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept the docs.comfy.org changelog / guide pages so new tabs opened
|
||||
* by help center actions don't hit the real site during tests.
|
||||
*/
|
||||
async stubDocsPage(): Promise<void> {
|
||||
await this.page
|
||||
.context()
|
||||
.route('https://docs.comfy.org/**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept outbound static URLs (discord, github, ...) so new tabs
|
||||
* opened by help center actions don't navigate to the real sites.
|
||||
*/
|
||||
async stubExternalPages(): Promise<void> {
|
||||
for (const pattern of [
|
||||
'https://www.comfy.org/**',
|
||||
'https://github.com/**'
|
||||
]) {
|
||||
await this.page.context().route(pattern, (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms the `popup` listener, runs the action that triggers `window.open`,
|
||||
* then waits for the popup's initial navigation to commit so `popup.url()`
|
||||
* doesn't race and return `about:blank`. Returns a parsed `URL` and closes
|
||||
* the popup.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const url = await waitForPopup(page, () => button.click())
|
||||
* expect(url.hostname).toBe('example.com')
|
||||
* ```
|
||||
*/
|
||||
export async function waitForPopup(
|
||||
page: Page,
|
||||
action: () => Promise<void>
|
||||
): Promise<URL> {
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await action()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const url = new URL(popup.url())
|
||||
await popup.close()
|
||||
return url
|
||||
}
|
||||
|
||||
export function createMockRelease(
|
||||
overrides: Partial<ReleaseNote> = {}
|
||||
): ReleaseNote {
|
||||
return {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: '0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the main comfyPageFixture so that depending on `helpCenter`
|
||||
* automatically boots the full Comfy app (via the underlying comfyPage
|
||||
* fixture's setup). Tests only need to destructure `helpCenter`.
|
||||
*/
|
||||
export const helpCenterFixture = comfyPageFixture.extend<{
|
||||
helpCenter: HelpCenterHelper
|
||||
}>({
|
||||
helpCenter: async ({ comfyPage }, use) => {
|
||||
await use(new HelpCenterHelper(comfyPage.page))
|
||||
}
|
||||
})
|
||||
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { LogsRawResponse } from '@/schemas/apiSchema'
|
||||
|
||||
export class LogsTerminalHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockRawLogs(messages: string[]) {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
|
||||
await pending
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
})
|
||||
return resolve
|
||||
}
|
||||
|
||||
async mockRawLogsError() {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
}
|
||||
|
||||
async mockSubscribeLogs() {
|
||||
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
|
||||
route.fulfill({ status: 200, body: '' })
|
||||
)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
return JSON.stringify({
|
||||
type: 'logs',
|
||||
data: { entries: LogsTerminalHelper.buildEntries(messages) }
|
||||
})
|
||||
}
|
||||
|
||||
private static buildRawLogsResponse(messages: string[]): LogsRawResponse {
|
||||
return {
|
||||
size: { cols: 80, row: 24 },
|
||||
entries: LogsTerminalHelper.buildEntries(messages)
|
||||
}
|
||||
}
|
||||
|
||||
private static buildEntries(messages: string[]) {
|
||||
return messages.map((m) => ({
|
||||
t: '1970-01-01T00:00:00.000Z',
|
||||
m: m.endsWith('\n') ? m : `${m}\n`
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export const logsTerminalFixture = base.extend<{
|
||||
logsTerminal: LogsTerminalHelper
|
||||
}>({
|
||||
logsTerminal: async ({ page }, use) => {
|
||||
await use(new LogsTerminalHelper(page))
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -120,6 +123,27 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -202,3 +226,13 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +60,25 @@ export class WorkflowHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async waitForDraftPersisted({ timeout = 5000 } = {}) {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
),
|
||||
{ timeout }
|
||||
async waitForDraftPersisted() {
|
||||
await this.comfyPage.page.waitForFunction(() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page and waits for the app to initialize.
|
||||
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
|
||||
* the URL hash (subgraph navigation state), so the app restores
|
||||
* exactly where the user left off.
|
||||
*/
|
||||
async reloadAndWaitForApp() {
|
||||
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await this.comfyPage.waitForAppReady()
|
||||
}
|
||||
|
||||
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.comfyPage.page.evaluate(
|
||||
(wf) => window.app!.loadGraphData(wf),
|
||||
|
||||
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
page: Page,
|
||||
nodeIds: string[]
|
||||
): Promise<MeasureResult> {
|
||||
return page.evaluate(
|
||||
({ ids, padding }) => {
|
||||
const canvas = window.app!.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
const selectedItems = canvas.selectedItems
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const item of selectedItems) {
|
||||
const rect = item.boundingRect
|
||||
minX = Math.min(minX, rect[0])
|
||||
minY = Math.min(minY, rect[1])
|
||||
maxX = Math.max(maxX, rect[0] + rect[2])
|
||||
maxY = Math.max(maxY, rect[1] + rect[3])
|
||||
}
|
||||
const selectionBounds =
|
||||
selectedItems.size > 0
|
||||
? {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
w: maxX - minX + 2 * padding,
|
||||
h: maxY - minY + 2 * padding
|
||||
}
|
||||
: null
|
||||
|
||||
const canvasEl = canvas.canvas as HTMLCanvasElement
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
const nodeVisualBounds: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${id}"]`
|
||||
) as HTMLElement | null
|
||||
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
|
||||
|
||||
if (isLegacyCanvasNodeWithoutVueDom) {
|
||||
const node = window.app!.graph._nodes.find(
|
||||
(n: { id: number | string }) => String(n.id) === id
|
||||
)
|
||||
if (!node) {
|
||||
throw new Error(`Node ${id} not found in graph`)
|
||||
}
|
||||
const rect = node.boundingRect
|
||||
nodeVisualBounds[id] = {
|
||||
x: rect[0],
|
||||
y: rect[1],
|
||||
w: rect[2],
|
||||
h: rect[3]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const domRect = nodeEl.getBoundingClientRect()
|
||||
const footerEls = nodeEl.querySelectorAll(
|
||||
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
|
||||
)
|
||||
let bottom = domRect.bottom
|
||||
for (const footerEl of footerEls) {
|
||||
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
|
||||
}
|
||||
|
||||
nodeVisualBounds[id] = {
|
||||
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
|
||||
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
|
||||
w: domRect.width / ds.scale,
|
||||
h: (bottom - domRect.top) / ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return { selectionBounds, nodeVisualBounds }
|
||||
},
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
@@ -115,6 +115,13 @@ export const TestIds = {
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
},
|
||||
helpCenter: {
|
||||
button: 'help-center-button',
|
||||
popup: 'help-center-popup',
|
||||
backdrop: 'help-center-backdrop',
|
||||
menuItem: (key: string) => `help-menu-item-${key}`,
|
||||
releaseItem: (version: string) => `help-release-item-${version}`
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
@@ -199,6 +206,13 @@ export const TestIds = {
|
||||
load3dViewer: {
|
||||
sidebar: 'load3d-viewer-sidebar'
|
||||
},
|
||||
terminal: {
|
||||
root: 'terminal-root',
|
||||
host: 'terminal-host',
|
||||
copyButton: 'terminal-copy-button',
|
||||
errorMessage: 'terminal-error-message',
|
||||
loadingSpinner: 'terminal-loading-spinner'
|
||||
},
|
||||
imageCompare: {
|
||||
viewport: 'image-compare-viewport',
|
||||
empty: 'image-compare-empty',
|
||||
@@ -211,34 +225,11 @@ export const TestIds = {
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Helper type for accessing nested TestIds (excludes function values)
|
||||
*/
|
||||
export type TestIdValue =
|
||||
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
|
||||
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
|
||||
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
|
||||
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
|
||||
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
|
||||
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
|
||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
|
||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]
|
||||
export type TestId<K extends keyof typeof TestIds> = Exclude<
|
||||
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
|
||||
(...args: never[]) => string
|
||||
>
|
||||
|
||||
export type TestIdValue = {
|
||||
[K in keyof typeof TestIds]: TestId<K>
|
||||
}[keyof typeof TestIds]
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
return {
|
||||
@@ -331,6 +332,22 @@ export class NodeReference {
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
/**
|
||||
* Toggle the node's collapsed state by simulating the same user interaction
|
||||
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
|
||||
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
|
||||
* element with `data-node-id`.
|
||||
*/
|
||||
async toggleCollapse() {
|
||||
const vueLocator = this.comfyPage.page.locator(
|
||||
`[data-node-id="${this.id}"]`
|
||||
)
|
||||
if ((await vueLocator.count()) > 0) {
|
||||
await new VueNodeFixture(vueLocator).toggleCollapse()
|
||||
return
|
||||
}
|
||||
await this.click('collapse')
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
STABLE_OUTPUT
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
test.describe('AssetHelper', () => {
|
||||
test.describe('operators and configuration', () => {
|
||||
test('creates helper with models via withModels operator', async ({
|
||||
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(2)
|
||||
expect(data.has_more).toBe(false)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets respects pagination params', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(10)
|
||||
expect(data.has_more).toBe(true)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(1)
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
|
||||
`${comfyPage.url}/api/assets/nonexistent-id`
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
|
||||
'renamed.safetensors'
|
||||
)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('DELETE /assets/:id removes asset from store', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(204)
|
||||
expect(assetApi.assetCount).toBe(1)
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets returns upload response', async ({ comfyPage }) => {
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { id: string; name: string }
|
||||
expect(data.id).toBe('custom-upload-001')
|
||||
expect(data.name).toBe('custom.safetensors')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets/download returns async download response', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { task_id: string; status: string }
|
||||
expect(data.task_id).toBe('download-task-001')
|
||||
expect(data.status).toBe('created')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mutation tracking', () => {
|
||||
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(mutations[0].method).toBe('POST')
|
||||
expect(mutations[1].method).toBe('PUT')
|
||||
expect(mutations[2].method).toBe('DELETE')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
|
||||
)
|
||||
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mockError', () => {
|
||||
test('returns error status for all asset routes', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(503)
|
||||
const data = body as { error: string }
|
||||
expect(data.error).toBe('Service Unavailable')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('clearMocks', () => {
|
||||
test('resets store, mutations, and unroutes handlers', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -1,98 +1,151 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
LogsTerminalHelper,
|
||||
logsTerminalFixture
|
||||
} from '@e2e/fixtures/helpers/LogsTerminalHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import {
|
||||
getClipboardText,
|
||||
interceptClipboardWrite
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
|
||||
|
||||
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('panel', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('opens to Logs tab via toggle button', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes via toggle button', async ({ comfyPage }) => {
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.bottomPanel.toggleButton.click()
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('switches from shortcuts to Logs tab', async ({ comfyPage }) => {
|
||||
await comfyPage.bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible()
|
||||
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.tab).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('should show Logs tab when terminal panel opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('terminal', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows loading spinner while logs are loading', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const resolveRaw = await logsTerminal.mockRawLogsPending()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
|
||||
|
||||
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
resolveRaw()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('renders initial log entries from the raw-logs API', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const logLine = 'Hello from ComfyUI backend!'
|
||||
await logsTerminal.mockRawLogs([logLine])
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
test('appends log entries received via WebSocket', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
const ws = await getWebSocket()
|
||||
const firstLine = 'First live log line'
|
||||
const secondLine = 'Second live log line'
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeHidden()
|
||||
})
|
||||
ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
secondLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('copy button copies terminal contents to clipboard', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const logLine = 'Copy me to the clipboard'
|
||||
await logsTerminal.mockRawLogs([logLine])
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const xtermContainer = bottomPanel.root.locator('.xterm')
|
||||
await expect(xtermContainer).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.logs.terminalRoot.hover()
|
||||
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
|
||||
await comfyPage.bottomPanel.logs.copyButton.click()
|
||||
|
||||
test('should render xterm container in terminal panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect
|
||||
.poll(() => getClipboardText(comfyPage.page))
|
||||
.toContain(logLine)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows error message when raw-logs API fails', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
await logsTerminal.mockRawLogsError()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
|
||||
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
|
||||
await expect(xtermScreen.first()).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText(
|
||||
'Unable to load logs'
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
44
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts
Normal file
44
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
|
||||
|
||||
test.describe('CancelSubscription dialog', { tag: '@ui' }, () => {
|
||||
let dialog: CancelSubscriptionDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new CancelSubscriptionDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with title and formatted date', async () => {
|
||||
await dialog.open('2025-12-31T12:00:00Z')
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.root).toContainText('December 31, 2025')
|
||||
})
|
||||
|
||||
test('"Keep subscription" button closes dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.keepSubscriptionButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
await dialog.open()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Cancel subscription" button initiates cancellation flow', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.confirmCancelButton).toBeEnabled()
|
||||
|
||||
await dialog.confirmCancelButton.click()
|
||||
|
||||
// Next state: dialog closes once the cancellation flow completes
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
|
||||
argv: ['main.py', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
|
||||
58
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts
Normal file
58
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
|
||||
|
||||
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
|
||||
let dialog: TopUpCreditsDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new TopUpCreditsDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with heading and preset amounts', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.preset10).toBeVisible()
|
||||
await expect(dialog.preset25).toBeVisible()
|
||||
await expect(dialog.preset50).toBeVisible()
|
||||
await expect(dialog.preset100).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays insufficient credits message when opened with flag', async () => {
|
||||
await dialog.open({ isInsufficientCredits: true })
|
||||
|
||||
await expect(dialog.insufficientHeading).toBeVisible()
|
||||
await expect(dialog.root).toContainText(
|
||||
"You don't have enough credits to run this workflow"
|
||||
)
|
||||
})
|
||||
|
||||
test('selecting a preset amount updates the pay amount', async () => {
|
||||
await dialog.open()
|
||||
|
||||
// Default preset is $50, click $10 instead
|
||||
await dialog.preset10.click()
|
||||
|
||||
await expect(dialog.payAmountInput).toHaveValue('10')
|
||||
})
|
||||
|
||||
test('close button dismisses dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.closeButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('pricing details link points to docs pricing page', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.pricingLink).toBeVisible()
|
||||
await expect(dialog.pricingLink).toHaveAttribute(
|
||||
'href',
|
||||
/partner-nodes\/pricing/
|
||||
)
|
||||
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('File input same-file reselection', () => {
|
||||
test('should allow uploading the same file twice via LoadImage node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const loadImageNode = loadImageNodes[0]
|
||||
const uploadWidget = await loadImageNode.getWidget(1)
|
||||
const fileWidget = await loadImageNode.getWidget(0)
|
||||
|
||||
// First upload
|
||||
const firstUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const firstChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await firstChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await firstUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'First upload should set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
|
||||
// Second upload of the SAME file — before the fix, the hidden input
|
||||
// retained the previous value and onchange did not fire.
|
||||
const secondUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const secondChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await secondChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await secondUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'Second upload of the same file should still set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
})
|
||||
})
|
||||
174
browser_tests/tests/helpCenter.spec.ts
Normal file
174
browser_tests/tests/helpCenter.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
createMockRelease,
|
||||
helpCenterFixture as test,
|
||||
waitForPopup
|
||||
} from '@e2e/fixtures/helpers/HelpCenterHelper'
|
||||
|
||||
test.describe('Help Center', () => {
|
||||
test.describe('popup visibility', () => {
|
||||
test('opens the popup and shows the backdrop when the sidebar button is clicked', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
await helpCenter.toggle()
|
||||
await expect(helpCenter.popup).toBeVisible()
|
||||
await expect(helpCenter.backdrop).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes when the backdrop is clicked', async ({ helpCenter }) => {
|
||||
await helpCenter.open()
|
||||
await helpCenter.closeViaBackdrop()
|
||||
await expect(helpCenter.popup).toBeHidden()
|
||||
})
|
||||
|
||||
test('closes after clicking a menu item that opens an external tab', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
await helpCenter.stubDocsPage()
|
||||
await helpCenter.open()
|
||||
|
||||
await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('docs').click()
|
||||
)
|
||||
|
||||
await expect(helpCenter.popup).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('popup positioning', () => {
|
||||
test('anchors to the left when sidebar location is left', async ({
|
||||
comfyPage,
|
||||
helpCenter
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
|
||||
await helpCenter.open()
|
||||
await expect(helpCenter.popup).toHaveClass(/sidebar-left/)
|
||||
await expect(helpCenter.popup).not.toHaveClass(/sidebar-right/)
|
||||
})
|
||||
|
||||
test('anchors to the right when sidebar location is right', async ({
|
||||
comfyPage,
|
||||
helpCenter
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
|
||||
await helpCenter.open()
|
||||
await expect(helpCenter.popup).toHaveClass(/sidebar-right/)
|
||||
await expect(helpCenter.popup).not.toHaveClass(/sidebar-left/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('menu item actions', () => {
|
||||
test.beforeEach(async ({ helpCenter }) => {
|
||||
await helpCenter.stubDocsPage()
|
||||
await helpCenter.stubExternalPages()
|
||||
await helpCenter.stubSupportPage()
|
||||
await helpCenter.open()
|
||||
})
|
||||
|
||||
test('Docs item opens docs.comfy.org/ in a new tab', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('docs').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('docs.comfy.org')
|
||||
expect(url.pathname).toBe('/')
|
||||
})
|
||||
|
||||
test('Discord item opens comfy.org/discord in a new tab', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('discord').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('www.comfy.org')
|
||||
expect(url.pathname).toBe('/discord')
|
||||
})
|
||||
|
||||
test('Github item opens the ComfyUI repo in a new tab', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('github').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('github.com')
|
||||
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
|
||||
})
|
||||
|
||||
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('help').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
})
|
||||
|
||||
test('Give Feedback item opens Contact Support in OSS mode', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('feedback').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("What's New releases", () => {
|
||||
test('renders only the three most recent releases', async ({
|
||||
comfyPage,
|
||||
helpCenter
|
||||
}) => {
|
||||
const versions = ['0.4.10', '0.4.9', '0.4.8', '0.4.7', '0.4.6']
|
||||
const now = Date.now()
|
||||
const releases = versions.map((version, idx) =>
|
||||
createMockRelease({
|
||||
id: idx + 1,
|
||||
version,
|
||||
published_at: new Date(now - idx * 60_000).toISOString()
|
||||
})
|
||||
)
|
||||
|
||||
await helpCenter.mockReleases(releases)
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
await helpCenter.open()
|
||||
|
||||
await expect(helpCenter.whatsNewSection).toBeVisible()
|
||||
await expect(helpCenter.releaseItems).toHaveCount(3)
|
||||
await expect(helpCenter.releaseItem('0.4.10')).toBeVisible()
|
||||
await expect(helpCenter.releaseItem('0.4.9')).toBeVisible()
|
||||
await expect(helpCenter.releaseItem('0.4.8')).toBeVisible()
|
||||
await expect(helpCenter.releaseItem('0.4.7')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clicking a release opens the changelog with a version anchor', async ({
|
||||
comfyPage,
|
||||
helpCenter
|
||||
}) => {
|
||||
const release = createMockRelease({ version: '0.3.50' })
|
||||
|
||||
await helpCenter.mockReleases([release])
|
||||
await helpCenter.stubDocsPage()
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
await helpCenter.open()
|
||||
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.releaseItem('0.3.50').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('docs.comfy.org')
|
||||
expect(url.pathname).toBe('/changelog')
|
||||
expect(url.hash).toBe('#v0-3-50')
|
||||
|
||||
await expect(helpCenter.popup).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
const REGULAR_ID = '3'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
type Layout = { ref: [number, number]; target: [number, number] }
|
||||
const LAYOUTS: Record<string, Layout> = {
|
||||
'bottom-left': { ref: [200, 100], target: [150, 500] },
|
||||
'bottom-right': { ref: [100, 100], target: [600, 500] }
|
||||
}
|
||||
|
||||
type NodeType = 'subgraph' | 'regular'
|
||||
type NodeState = 'expanded' | 'collapsed'
|
||||
type Position = 'bottom-left' | 'bottom-right'
|
||||
|
||||
function getTargetId(type: NodeType): string {
|
||||
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
|
||||
}
|
||||
|
||||
function getRefId(type: NodeType): string {
|
||||
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
|
||||
}
|
||||
|
||||
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
}
|
||||
|
||||
async function assertSelectionEncompassesNodes(
|
||||
page: Page,
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(2)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await measureSelectionBounds(page, nodeIds)
|
||||
expect(result.selectionBounds).not.toBeNull()
|
||||
|
||||
const sel = result.selectionBounds!
|
||||
const selRight = sel.x + sel.w
|
||||
const selBottom = sel.y + sel.h
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const vis = result.nodeVisualBounds[nodeId]
|
||||
expect(vis).toBeDefined()
|
||||
|
||||
expect(sel.x).toBeLessThanOrEqual(vis.x)
|
||||
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
|
||||
expect(sel.y).toBeLessThanOrEqual(vis.y)
|
||||
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const vueCases: ReadonlyArray<{
|
||||
type: NodeType
|
||||
state: NodeState
|
||||
pos: Position
|
||||
}> = [
|
||||
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
|
||||
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
|
||||
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
|
||||
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
|
||||
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
|
||||
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
|
||||
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
|
||||
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
|
||||
]
|
||||
|
||||
for (const { type, state, pos } of vueCases) {
|
||||
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const targetId = getTargetId(type)
|
||||
const refId = getRefId(type)
|
||||
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[refId]: LAYOUTS[pos].ref,
|
||||
[targetId]: LAYOUTS[pos].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
||||
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
refId,
|
||||
targetId
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('collapsed node narrows bounding box when bypass is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
||||
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
|
||||
test('collapsed node widens bounding box when bypass is added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
||||
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (legacy mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
|
||||
{ state: 'expanded', pos: 'bottom-left' },
|
||||
{ state: 'expanded', pos: 'bottom-right' },
|
||||
{ state: 'collapsed', pos: 'bottom-left' },
|
||||
{ state: 'collapsed', pos: 'bottom-right' }
|
||||
]
|
||||
|
||||
for (const { state, pos } of legacyCases) {
|
||||
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
|
||||
[REGULAR_ID]: LAYOUTS[pos].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,83 +1,65 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface SubgraphNodePosition {
|
||||
id: NodeId
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function getSubgraphNodePositions(): SubgraphNodePosition[] {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph node positions after draft reload',
|
||||
{ tag: ['@subgraph'] },
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
|
||||
// Enable workflow persistence explicitly
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
|
||||
|
||||
// Load a workflow containing a subgraph
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// Enter the subgraph programmatically (fixture node is too small for UI click)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
if (sg) window.app!.canvas.setGraph(sg)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const positionsBefore = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
|
||||
let positionsBefore: SubgraphNodePosition[] = []
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const positions = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
return positions.length
|
||||
positionsBefore = await comfyPage.page.evaluate(
|
||||
getSubgraphNodePositions
|
||||
)
|
||||
return positionsBefore.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Wait for the debounced draft persistence to flush to localStorage
|
||||
expect(
|
||||
positionsBefore.length,
|
||||
'Expected nodes before reload'
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.workflow.waitForDraftPersisted()
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
|
||||
// Reload the page (draft auto-loads with hash preserved)
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
await comfyPage.page.locator('.p-blockui-mask').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for subgraph auto-entry via hash navigation
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
|
||||
.toBe(true)
|
||||
|
||||
// Verify all internal node positions are preserved
|
||||
for (const before of positionsBefore) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const positionsNow = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
const positionsNow = await comfyPage.page.evaluate(
|
||||
getSubgraphNodePositions
|
||||
)
|
||||
const after = positionsNow.find((n) => n.id === before.id)
|
||||
if (!after) return null
|
||||
return { x: after.x, y: after.y }
|
||||
|
||||
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Topbar menu commands', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('New command creates a new workflow tab', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
await topbar.triggerTopbarCommand(['New'])
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('File > Save opens save dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['File', 'Save'])
|
||||
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await expect(saveDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('View > Bottom Panel toggles bottom panel', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
512
browser_tests/tests/vueNodes/glslPreview.spec.ts
Normal file
512
browser_tests/tests/vueNodes/glslPreview.spec.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const GLSL_NODE_ID = '1'
|
||||
const GLSL_NODE_TITLE = 'GLSL Shader'
|
||||
const PRIMITIVE_FLOAT_NODE_TITLE = 'Float'
|
||||
const PRIMITIVE_INT_NODE_TITLE = 'Int'
|
||||
const PRIMITIVE_BOOLEAN_NODE_TITLE = 'Boolean'
|
||||
|
||||
const RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform vec2 u_resolution;',
|
||||
'in vec2 v_texCoord;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Shader that drives every pixel's red channel from `u_float0` alone. */
|
||||
const FLOAT_RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform float u_float0;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Wait until an `<img>` locator has finished decoding. */
|
||||
async function waitForImageDecoded(image: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
/** Page-object helper bound to the GLSLShader node under test. */
|
||||
class GLSLShaderNode {
|
||||
readonly node: Locator
|
||||
/**
|
||||
* Any `<img>` inside the node whose src is a `blob:` URL. Covers both
|
||||
* the standalone-node `LivePreview` path and the subgraph-wrapped
|
||||
* promoted-preview path (where the blob surfaces via `ImagePreview`).
|
||||
*/
|
||||
readonly previewImage: Locator
|
||||
readonly shaderTextbox: Locator
|
||||
readonly widthInput: Locator
|
||||
readonly heightInput: Locator
|
||||
|
||||
constructor(
|
||||
private readonly comfyPage: ComfyPage,
|
||||
readonly nodeId: string,
|
||||
readonly title: string
|
||||
) {
|
||||
this.node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
this.previewImage = this.node.locator('img[src^="blob:"]')
|
||||
this.shaderTextbox = this.node.getByRole('textbox', {
|
||||
name: 'fragment_shader'
|
||||
})
|
||||
this.widthInput = this.node
|
||||
.getByLabel('size_mode.width', { exact: true })
|
||||
.locator('input')
|
||||
this.heightInput = this.node
|
||||
.getByLabel('size_mode.height', { exact: true })
|
||||
.locator('input')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `execution_start` + `executed` with an image output for this node,
|
||||
* which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`.
|
||||
*/
|
||||
async simulateExecutionOutput(ws: WebSocketRoute) {
|
||||
const exec = new ExecutionHelper(this.comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await this.comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
exec.executed(jobId, this.nodeId, {
|
||||
images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
exec.executionSuccess(jobId)
|
||||
}
|
||||
|
||||
async getPreviewSrc(): Promise<string | null> {
|
||||
return this.previewImage.getAttribute('src')
|
||||
}
|
||||
|
||||
async getPreviewNaturalSize(): Promise<{ width: number; height: number }> {
|
||||
return this.previewImage.evaluate((el: HTMLImageElement) => ({
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
}))
|
||||
}
|
||||
|
||||
async selectSizeMode(option: 'from_input' | 'custom'): Promise<void> {
|
||||
await this.comfyPage.vueNodes.selectComboOption(
|
||||
this.title,
|
||||
'size_mode',
|
||||
option
|
||||
)
|
||||
}
|
||||
|
||||
/** Wait until the preview image has a blob: URL and return it. */
|
||||
async waitForBlobSrc(): Promise<string> {
|
||||
await expect.poll(() => this.getPreviewSrc()).toMatch(/^blob:/)
|
||||
return (await this.getPreviewSrc())!
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the preview blob to a 2D canvas and verify every pixel matches.
|
||||
*/
|
||||
async expectEveryPixelToBe(
|
||||
expected: [number, number, number, number],
|
||||
tolerance = 1
|
||||
): Promise<void> {
|
||||
await waitForImageDecoded(this.previewImage)
|
||||
const mismatch = await this.previewImage.evaluate(
|
||||
(
|
||||
img: HTMLImageElement,
|
||||
args: { exp: [number, number, number, number]; tol: number }
|
||||
) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
if (Math.abs(data[i + c] - args.exp[c]) > args.tol) {
|
||||
return {
|
||||
index: i / 4,
|
||||
actual: [data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
{ exp: expected, tol: tolerance }
|
||||
)
|
||||
const message = mismatch
|
||||
? `expected every pixel ≈ [${expected.join(',')}] ±${tolerance}; pixel ${mismatch.index} was [${mismatch.actual.join(',')}]`
|
||||
: undefined
|
||||
expect(mismatch, message).toBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an image file onto a LoadImage node and wait for its preview to render.
|
||||
*/
|
||||
async function dropImageOntoLoadImage(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
const box = await node.boundingBox()
|
||||
expect(
|
||||
box,
|
||||
`LoadImage node ${nodeId} must have a bounding box`
|
||||
).not.toBeNull()
|
||||
await comfyPage.dragDrop.dragAndDropFile(filename, {
|
||||
dropPosition: { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
})
|
||||
const preview = node.locator('.image-preview img')
|
||||
await expect(preview).toBeVisible()
|
||||
await waitForImageDecoded(preview)
|
||||
}
|
||||
|
||||
test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => {
|
||||
test.describe('standalone node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a blob preview into the node after execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('no preview is present before execution', async () => {
|
||||
await expect(glsl.previewImage).toHaveCount(0)
|
||||
})
|
||||
|
||||
await test.step('execution populates preview with a blob URL', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
})
|
||||
})
|
||||
|
||||
test('refreshes the preview when the fragment shader is edited', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('editing the shader replaces the blob URL', async () => {
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test('custom size_mode controls rendered resolution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('switch size_mode to custom and set width/height', async () => {
|
||||
await glsl.selectSizeMode('custom')
|
||||
|
||||
await expect(glsl.widthInput).toBeVisible()
|
||||
await expect(glsl.heightInput).toBeVisible()
|
||||
|
||||
await glsl.widthInput.fill('16')
|
||||
await glsl.widthInput.blur()
|
||||
await glsl.heightInput.fill('32')
|
||||
await glsl.heightInput.blur()
|
||||
})
|
||||
|
||||
await test.step('executed preview uses the custom resolution', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 16, height: 32 })
|
||||
})
|
||||
})
|
||||
|
||||
test('logs a compile failure then recovers when shader becomes valid again', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
// Captures every `[GLSL] shader compilation failed` warning emitted
|
||||
// by `useGLSLPreview.ts` during this test.
|
||||
const compileFailure = comfyPage.page.waitForEvent('console', {
|
||||
predicate: (msg) =>
|
||||
msg.type() === 'warning' &&
|
||||
msg.text().includes('[GLSL] shader compilation failed')
|
||||
})
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const goodSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await glsl.shaderTextbox.fill('not valid glsl at all')
|
||||
await compileFailure // ensures the invalid shader actually hit the compiler
|
||||
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(goodSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive float source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_float')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveFloat value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const floatValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_FLOAT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: floatValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(floatValueWidget)
|
||||
|
||||
// Drive every pixel's red channel directly from u_float0 so the
|
||||
// before/after refresh is visually obvious (dim red → pure red).
|
||||
await glsl.shaderTextbox.fill(FLOAT_RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
// Workflow default is 0.25 → ~64; RGBA16F → PNG round-trip can drift.
|
||||
await glsl.expectEveryPixelToBe([64, 0, 0, 255], 2)
|
||||
|
||||
await test.step('changing the upstream float value re-renders the preview', async () => {
|
||||
await expect(floatValueInput).toBeVisible()
|
||||
await floatValueInput.fill('1.0')
|
||||
await floatValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with upstream LoadImage', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_loadimage')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
const LOAD_IMAGE_NODE_ID = '2'
|
||||
|
||||
test('uses upstream image dimensions', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
})
|
||||
|
||||
test('ensures shaders are correctly executed', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive int source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_int')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveInt value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const intValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_INT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: intValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(intValueWidget)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('changing the upstream int value re-renders the preview', async () => {
|
||||
await expect(intValueInput).toBeVisible()
|
||||
await intValueInput.fill('100')
|
||||
await intValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
await test.step('upstream int value flows through as the u_int0 uniform', async () => {
|
||||
// Shader writes vec4(float(u_int0) / 100.0, 0, 0, 1); value 100 → red.
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive boolean source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_bool')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('upstream PrimitiveBoolean value flows through as the u_bool0 uniform', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const booleanToggle = comfyPage.vueNodes
|
||||
.getNodeByTitle(PRIMITIVE_BOOLEAN_NODE_TITLE)
|
||||
.getByRole('switch', { name: 'value' })
|
||||
|
||||
await test.step('boolean=false renders blue', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
// Blue (non-max channel) through RGBA16F → PNG round-trip can drift by 2.
|
||||
await glsl.expectEveryPixelToBe([0, 0, 255, 255], 2)
|
||||
})
|
||||
|
||||
await test.step('toggling boolean=true re-renders red', async () => {
|
||||
const blueSrc = (await glsl.getPreviewSrc())!
|
||||
await expect(booleanToggle).toBeVisible()
|
||||
await booleanToggle.click()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(blueSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_in_subgraph')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a GLSL blob preview on the outer subgraph node', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inside a subgraph, the GLSL renderer writes the blob preview to the
|
||||
// INNER GLSLShader's locator; the outer subgraph node surfaces it via
|
||||
// the promoted-preview path (ImagePreview component), not LivePreview.
|
||||
// Either way, the observable signal is an <img> with a blob: src.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph with uniform source', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'nodes/glsl_shader_subgraph_with_float'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('extracts uniform sources from inner upstream widgets', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inner PrimitiveFloat is wired to the inner GLSLShader's floats.u_float0
|
||||
// input. useGLSLUniforms.extractUniformSources should pick it up and feed
|
||||
// 1.0 as u_float0 — shader outputs vec4(u_float0, 0, 0, 1) → red.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph With Float'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -209,7 +209,8 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'image_preview_drag_and_dropped.png'
|
||||
'image_preview_drag_and_dropped.png',
|
||||
{ maxDiffPixelRatio: 0.02 }
|
||||
)
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 43 KiB |
@@ -26,6 +26,8 @@ An Entity Component System (ECS) separates **identity** (entities), **data** (co
|
||||
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
|
||||
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
|
||||
|
||||
For the full problem catalog with line-level code references, see [Entity System Structural Problems](../architecture/entity-problems.md). For a map of all current entity relationships, see [Entity Interactions](../architecture/entity-interactions.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
|
||||
@@ -172,7 +174,7 @@ Systems are pure functions that query the World for entities with specific compo
|
||||
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
|
||||
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
|
||||
|
||||
System design is deferred to a future ADR.
|
||||
System design is deferred to a future ADR. For detailed before/after walkthroughs of how lifecycle operations (node removal, link creation, subgraph nesting, etc.) transform under ECS, see [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md).
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
@@ -182,6 +184,8 @@ System design is deferred to a future ADR.
|
||||
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
|
||||
|
||||
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
|
||||
|
||||
### Relationship to ADR 0003 (Command Pattern / CRDT)
|
||||
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
|
||||
@@ -231,6 +235,23 @@ Planned mitigations for the ECS render path:
|
||||
|
||||
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
|
||||
|
||||
## Supporting Documents
|
||||
|
||||
Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
## Notes
|
||||
|
||||
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
|
||||
|
||||
@@ -284,7 +284,10 @@ export default defineConfig([
|
||||
message:
|
||||
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
|
||||
}
|
||||
]
|
||||
],
|
||||
// Tests routinely define stub and harness components side-by-side with the
|
||||
// system under test, which is a distinct use case from production SFCs.
|
||||
'vue/one-component-per-file': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.4",
|
||||
"version": "1.44.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -28,6 +28,7 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -44,11 +45,6 @@ export type {
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
ClaimInviteCodeResponse,
|
||||
ClaimInviteCodeResponses,
|
||||
ClientOptions,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
@@ -113,6 +109,13 @@ export type {
|
||||
CreateWorkflowVersionRequest,
|
||||
CreateWorkflowVersionResponse,
|
||||
CreateWorkflowVersionResponses,
|
||||
CreateWorkspaceApiKeyData,
|
||||
CreateWorkspaceApiKeyError,
|
||||
CreateWorkspaceApiKeyErrors,
|
||||
CreateWorkspaceApiKeyRequest,
|
||||
CreateWorkspaceApiKeyResponse,
|
||||
CreateWorkspaceApiKeyResponse2,
|
||||
CreateWorkspaceApiKeyResponses,
|
||||
CreateWorkspaceData,
|
||||
CreateWorkspaceError,
|
||||
CreateWorkspaceErrors,
|
||||
@@ -237,12 +240,16 @@ export type {
|
||||
GetBillingStatusErrors,
|
||||
GetBillingStatusResponse,
|
||||
GetBillingStatusResponses,
|
||||
GetCustomNodeProxyData,
|
||||
GetCustomNodeProxyErrors,
|
||||
GetCustomNodeProxyResponses,
|
||||
GetDeletionRequestData,
|
||||
GetDeletionRequestError,
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
@@ -263,7 +270,9 @@ export type {
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHealthData,
|
||||
GetHealthError,
|
||||
GetHealthErrors,
|
||||
GetHealthResponse,
|
||||
GetHealthResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
@@ -285,11 +294,6 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
GetInviteCodeStatusResponse,
|
||||
GetInviteCodeStatusResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -339,9 +343,19 @@ export type {
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetNodeByIdData,
|
||||
GetNodeByIdErrors,
|
||||
GetNodeByIdResponses,
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetNodeInfoSchemaData,
|
||||
GetNodeInfoSchemaResponses,
|
||||
GetNodeReplacementsData,
|
||||
GetNodeReplacementsError,
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetPaymentPortalData,
|
||||
@@ -422,11 +436,15 @@ export type {
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
GetUserResponses,
|
||||
GetUsersRawData,
|
||||
GetUsersRawErrors,
|
||||
GetUsersRawResponses,
|
||||
GetUsersInfoData,
|
||||
GetUsersInfoError,
|
||||
GetUsersInfoErrors,
|
||||
GetUsersInfoResponse,
|
||||
GetUsersInfoResponses,
|
||||
GetVhsQueryVideoData,
|
||||
GetVhsQueryVideoError,
|
||||
GetVhsQueryVideoErrors,
|
||||
GetVhsQueryVideoResponse,
|
||||
GetVhsQueryVideoResponses,
|
||||
GetVhsViewAudioData,
|
||||
GetVhsViewAudioErrors,
|
||||
@@ -487,8 +505,6 @@ export type {
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
@@ -551,6 +567,12 @@ export type {
|
||||
ListWorkflowsErrors,
|
||||
ListWorkflowsResponse,
|
||||
ListWorkflowsResponses,
|
||||
ListWorkspaceApiKeysData,
|
||||
ListWorkspaceApiKeysError,
|
||||
ListWorkspaceApiKeysErrors,
|
||||
ListWorkspaceApiKeysResponse,
|
||||
ListWorkspaceApiKeysResponse2,
|
||||
ListWorkspaceApiKeysResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesError,
|
||||
ListWorkspaceInvitesErrors,
|
||||
@@ -601,6 +623,9 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostCustomNodeProxyData,
|
||||
PostCustomNodeProxyErrors,
|
||||
PostCustomNodeProxyResponses,
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
@@ -661,6 +686,11 @@ export type {
|
||||
ResubscribeResponse,
|
||||
ResubscribeResponse2,
|
||||
ResubscribeResponses,
|
||||
RevokeWorkspaceApiKeyData,
|
||||
RevokeWorkspaceApiKeyError,
|
||||
RevokeWorkspaceApiKeyErrors,
|
||||
RevokeWorkspaceApiKeyResponse,
|
||||
RevokeWorkspaceApiKeyResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteError,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
@@ -668,13 +698,6 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SendUserInviteEmailData,
|
||||
SendUserInviteEmailError,
|
||||
SendUserInviteEmailErrors,
|
||||
SendUserInviteEmailRequest,
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -718,6 +741,12 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateHubWorkflowData,
|
||||
UpdateHubWorkflowError,
|
||||
UpdateHubWorkflowErrors,
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -734,6 +763,11 @@ export type {
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
UpdateSubscriptionCacheResponse,
|
||||
UpdateSubscriptionCacheResponses,
|
||||
UpdateWorkflowData,
|
||||
UpdateWorkflowError,
|
||||
UpdateWorkflowErrors,
|
||||
@@ -765,6 +799,13 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
VerifyApiKeyRequest,
|
||||
VerifyApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyData,
|
||||
VerifyWorkspaceApiKeyError,
|
||||
VerifyWorkspaceApiKeyErrors,
|
||||
VerifyWorkspaceApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyResponses,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
@@ -779,6 +820,7 @@ export type {
|
||||
WorkflowVersionContentResponse,
|
||||
WorkflowVersionResponse,
|
||||
Workspace,
|
||||
WorkspaceApiKeyInfo,
|
||||
WorkspaceSummary,
|
||||
WorkspaceWithRole
|
||||
} from './types.gen'
|
||||
|
||||
884
packages/ingest-types/src/types.gen.ts
generated
884
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
353
packages/ingest-types/src/zod.gen.ts
generated
353
packages/ingest-types/src/zod.gen.ts
generated
@@ -20,6 +20,32 @@ export const zHubAssetUploadUrlRequest = z.object({
|
||||
content_type: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
|
||||
*
|
||||
* * field omitted or null — leave unchanged
|
||||
* * string field = "" — clear (for clearable string fields)
|
||||
* * array field = [] — clear the list
|
||||
* * any other value — set to the provided value
|
||||
*
|
||||
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
|
||||
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
|
||||
*
|
||||
*/
|
||||
export const zUpdateHubWorkflowRequest = z.object({
|
||||
name: z.string().min(1).nullish(),
|
||||
description: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
models: z.array(z.string()).nullish(),
|
||||
custom_nodes: z.array(z.string()).nullish(),
|
||||
tutorial_url: z.string().nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_token: z.string().nullish(),
|
||||
thumbnail_comparison_token: z.string().nullish(),
|
||||
sample_image_tokens_or_urls: z.array(z.string()).nullish(),
|
||||
metadata: z.record(z.unknown()).nullish()
|
||||
})
|
||||
|
||||
export const zPublishHubWorkflowRequest = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
@@ -134,8 +160,43 @@ export const zHubWorkflowTemplateEntry = z.object({
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().optional(),
|
||||
size: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
vram: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
usage: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
searchRank: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
isEssential: z.boolean().optional(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
@@ -641,6 +702,53 @@ export const zJwksResponse = z.object({
|
||||
keys: z.array(zJwkKey)
|
||||
})
|
||||
|
||||
export const zVerifyApiKeyResponse = z.object({
|
||||
user_id: z.string(),
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
is_admin: z.boolean(),
|
||||
workspace_id: z.string(),
|
||||
workspace_type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member']),
|
||||
has_funds: z.boolean(),
|
||||
is_active: z.boolean(),
|
||||
permissions: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zVerifyApiKeyRequest = z.object({
|
||||
api_key: z.string()
|
||||
})
|
||||
|
||||
export const zWorkspaceApiKeyInfo = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
revoked_at: z.string().datetime().optional(),
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
export const zListWorkspaceApiKeysResponse = z.object({
|
||||
api_keys: z.array(zWorkspaceApiKeyInfo)
|
||||
})
|
||||
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
export const zAcceptInviteResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string()
|
||||
@@ -979,22 +1087,6 @@ export const zAssetCreated = zAsset.and(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Response after sending an invite email
|
||||
*/
|
||||
export const zSendUserInviteEmailResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to send an invite email to a user
|
||||
*/
|
||||
export const zSendUserInviteEmailRequest = z.object({
|
||||
email: z.string(),
|
||||
force: z.boolean().optional().default(false)
|
||||
})
|
||||
|
||||
export const zSetReviewStatusResponse = z.object({
|
||||
share_ids: z.array(z.string()),
|
||||
status: z.enum(['approved', 'rejected'])
|
||||
@@ -1005,22 +1097,6 @@ export const zSetReviewStatusRequest = z.object({
|
||||
status: z.enum(['approved', 'rejected'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully claiming an invite code
|
||||
*/
|
||||
export const zInviteCodeClaimResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Invite code status response
|
||||
*/
|
||||
export const zInviteCodeStatusResponse = z.object({
|
||||
claimed: z.boolean(),
|
||||
expired: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after deleting a session cookie
|
||||
*/
|
||||
@@ -1040,6 +1116,7 @@ export const zCreateSessionResponse = z.object({
|
||||
* User information response
|
||||
*/
|
||||
export const zUserResponse = z.object({
|
||||
id: z.string(),
|
||||
status: z.string()
|
||||
})
|
||||
|
||||
@@ -1194,8 +1271,16 @@ export const zQueueManageRequest = z.object({
|
||||
* Queue information with pending and running jobs
|
||||
*/
|
||||
export const zQueueInfo = z.object({
|
||||
queue_running: z.array(z.array(z.unknown())).optional(),
|
||||
queue_pending: z.array(z.array(z.unknown())).optional()
|
||||
queue_running: z
|
||||
.array(
|
||||
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
|
||||
)
|
||||
.optional(),
|
||||
queue_pending: z
|
||||
.array(
|
||||
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1315,6 +1400,10 @@ export const zExportDownloadUrlResponse = z.object({
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string()
|
||||
@@ -1427,6 +1516,17 @@ export const zGetFeaturesResponse = z.object({
|
||||
max_upload_size: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zGetNodeReplacementsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Node replacement mappings
|
||||
*/
|
||||
export const zGetNodeReplacementsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetWorkflowTemplatesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1588,7 +1688,7 @@ export const zViewFileData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - File content returned (used when channel or res parameter is present)
|
||||
* Processed PNG image with extracted channel
|
||||
*/
|
||||
export const zViewFileResponse = z.string()
|
||||
|
||||
@@ -2429,6 +2529,56 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of API keys
|
||||
*/
|
||||
export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse
|
||||
|
||||
export const zCreateWorkspaceApiKeyData = z.object({
|
||||
body: zCreateWorkspaceApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* API key created (plaintext returned once)
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse
|
||||
|
||||
export const zRevokeWorkspaceApiKeyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* API key revoked
|
||||
*/
|
||||
export const zRevokeWorkspaceApiKeyResponse = z.void()
|
||||
|
||||
export const zVerifyWorkspaceApiKeyData = z.object({
|
||||
body: zVerifyApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
include_billing: z.boolean().optional().default(false)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Key is valid
|
||||
*/
|
||||
export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse
|
||||
|
||||
export const zGetUserData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2440,43 +2590,6 @@ export const zGetUserData = z.object({
|
||||
*/
|
||||
export const zGetUserResponse = zUserResponse
|
||||
|
||||
export const zGetInviteCodeStatusData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
code: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - invite code exists
|
||||
*/
|
||||
export const zGetInviteCodeStatusResponse = zInviteCodeStatusResponse
|
||||
|
||||
export const zClaimInviteCodeData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
code: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - invite code claimed successfully
|
||||
*/
|
||||
export const zClaimInviteCodeResponse = zInviteCodeClaimResponse
|
||||
|
||||
export const zSendUserInviteEmailData = z.object({
|
||||
body: zSendUserInviteEmailRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - invite email sent successfully
|
||||
*/
|
||||
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
|
||||
|
||||
export const zSetReviewStatusData = z.object({
|
||||
body: zSetReviewStatusRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -2488,6 +2601,19 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Updated hub workflow detail
|
||||
*/
|
||||
export const zUpdateHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zGetDeletionRequestData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2527,6 +2653,23 @@ export const zReportPartnerUsageData = z.object({
|
||||
*/
|
||||
export const zReportPartnerUsageResponse = zPartnerUsageResponse
|
||||
|
||||
export const zUpdateSubscriptionCacheData = z.object({
|
||||
body: z.object({
|
||||
user_id: z.string(),
|
||||
is_active: z.boolean(),
|
||||
tier: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Cache updated successfully
|
||||
*/
|
||||
export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetJobStatusData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -2991,6 +3134,25 @@ export const zGetExtensionsData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* URL paths (relative to web root) of available extension JS files
|
||||
*/
|
||||
export const zGetExtensionsResponse = z.array(z.string())
|
||||
|
||||
export const zGetNodeInfoSchemaData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetNodeByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetVhsViewVideoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3019,12 +3181,32 @@ export const zGetVhsQueryVideoData = z.object({
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetUsersRawData = z.object({
|
||||
/**
|
||||
* Video metadata
|
||||
*/
|
||||
export const zGetVhsQueryVideoResponse = z.object({
|
||||
source: z.object({
|
||||
size: z.tuple([z.number().int(), z.number().int()]),
|
||||
fps: z.number(),
|
||||
frames: z.number().int(),
|
||||
duration: z.number()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetUsersInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Userdata storage information
|
||||
*/
|
||||
export const zGetUsersInfoResponse = z.object({
|
||||
storage: z.string(),
|
||||
migrated: z.boolean()
|
||||
})
|
||||
|
||||
export const zGetApiViewVideoAliasData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3065,6 +3247,11 @@ export const zGetHealthData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Service is healthy
|
||||
*/
|
||||
export const zGetHealthResponse = z.string()
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3134,3 +3321,19 @@ export const zGetStaticExtensionsData = z.object({
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetCustomNodeProxyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostCustomNodeProxyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
252
packages/registry-types/src/comfyRegistryTypes.ts
generated
252
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -2907,7 +2907,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead. */
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/poll instead. */
|
||||
post: operations["veoPoll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -10482,77 +10482,196 @@ export interface components {
|
||||
};
|
||||
VeoGenVidRequest: {
|
||||
instances?: {
|
||||
/** @description Text description of the video */
|
||||
/** @description Text description of the video to generate */
|
||||
prompt: string;
|
||||
/** @description Optional image to guide video generation */
|
||||
/** @description Optional first frame image to guide video generation */
|
||||
image?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional last frame image to guide video generation */
|
||||
/** @description Optional last frame image. Used with image to generate video between first and last frames. Supported by Veo 3.0+ models. */
|
||||
lastFrame?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional reference images to guide video generation. Supports up to 3 asset images or 1 style image. Supported by Veo 3.1 models (preview). */
|
||||
referenceImages?: {
|
||||
image: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Type of reference image
|
||||
* @enum {string}
|
||||
*/
|
||||
referenceType: "asset" | "style";
|
||||
/** @description Optional identifier for the reference image */
|
||||
referenceId?: string;
|
||||
}[];
|
||||
/** @description Optional input video for video extension or editing. Incompatible with image and referenceImages. */
|
||||
video?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded video bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the input video */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the video
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "video/mov" | "video/mpeg" | "video/mp4" | "video/mpg" | "video/avi" | "video/wmv" | "video/mpegps" | "video/x-flv";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Camera motion type. Requires image to be provided.
|
||||
* @enum {string}
|
||||
*/
|
||||
cameraControl?: "fixed" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "truck_left" | "truck_right" | "pedestal_up" | "pedestal_down" | "push_in" | "pull_out";
|
||||
/** @description Optional mask for video editing. Applies to input video. */
|
||||
mask?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded mask bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI to mask file */
|
||||
gcsUri?: string;
|
||||
/** @description MIME type of the mask (image/png, image/jpeg, image/webp, or video formats) */
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description How the mask is applied
|
||||
* @enum {string}
|
||||
*/
|
||||
maskMode?: "insert" | "remove" | "remove_static" | "outpaint";
|
||||
} & (unknown | unknown);
|
||||
}[];
|
||||
parameters?: {
|
||||
/** @example 16:9 */
|
||||
aspectRatio?: string;
|
||||
/**
|
||||
* @description Aspect ratio of the generated video. Default: 16:9
|
||||
* @example 16:9
|
||||
* @enum {string}
|
||||
*/
|
||||
aspectRatio?: "16:9" | "9:16";
|
||||
/** @description Text describing what to avoid in the generated video */
|
||||
negativePrompt?: string;
|
||||
/** @enum {string} */
|
||||
personGeneration?: "ALLOW" | "BLOCK";
|
||||
/**
|
||||
* @description Controls people in generated videos. Default: allow_adult
|
||||
* @enum {string}
|
||||
*/
|
||||
personGeneration?: "dont_allow" | "allow_adult" | "allowAll";
|
||||
/** @description Number of videos to generate. If not specified, 1 video is generated. */
|
||||
sampleCount?: number;
|
||||
/** Format: uint32 */
|
||||
/**
|
||||
* Format: uint32
|
||||
* @description Random seed for deterministic output. Different seeds used per video if sampleCount > 1.
|
||||
*/
|
||||
seed?: number;
|
||||
/** @description Optional Cloud Storage URI to upload the video */
|
||||
/** @description Cloud Storage URI (gs://) for saving generated videos */
|
||||
storageUri?: string;
|
||||
/** @description Target duration of the generated video in seconds. Veo 2: 5-8. Veo 3/3.1: 4, 6, or 8. Default: 8 */
|
||||
durationSeconds?: number;
|
||||
/** @description Frame rate of generated videos in frames per second */
|
||||
fps?: number;
|
||||
/** @description Automatically improve prompt for higher quality. Defaults to true. */
|
||||
enhancePrompt?: boolean;
|
||||
/** @description Generate audio for the video. Only supported by veo 3 models. */
|
||||
/** @description Whether to generate audio along with the video. Defaults to true. Supported by Veo 3.0+ models. */
|
||||
generateAudio?: boolean;
|
||||
/**
|
||||
* @description Output video resolution. Supported by Veo 3.0+ models. Default: 720p
|
||||
* @enum {string}
|
||||
*/
|
||||
resolution?: "720p" | "1080p" | "4k";
|
||||
/**
|
||||
* @description Resize approach for input image. Default: pad
|
||||
* @enum {string}
|
||||
*/
|
||||
resizeMode?: "pad" | "crop";
|
||||
/**
|
||||
* @description Video compression quality. Default: optimized
|
||||
* @enum {string}
|
||||
*/
|
||||
compressionQuality?: "optimized" | "lossless";
|
||||
/**
|
||||
* @description Operation type for the video generation request
|
||||
* @enum {string}
|
||||
*/
|
||||
task?: "textToVideo" | "imageToVideo" | "referenceToVideo" | "edit" | "extend" | "upscale";
|
||||
/** @description Cloud Pub/Sub topic for progress updates (projects/{project}/topics/{topic}) */
|
||||
pubsubTopic?: string;
|
||||
};
|
||||
};
|
||||
/** @description Response from a Veo video generation request. Contains the operation name for polling. */
|
||||
VeoGenVidResponse: {
|
||||
/**
|
||||
* @description Operation resource name
|
||||
* @description Operation resource name used to poll for results via fetchPredictOperation
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
VeoGenVidPollRequest: {
|
||||
/**
|
||||
* @description Full operation name (from predict response)
|
||||
* @description Full operation name returned from the generate response
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID
|
||||
*/
|
||||
operationName: string;
|
||||
};
|
||||
/** @description Response from polling a Veo video generation operation */
|
||||
VeoGenVidPollResponse: {
|
||||
/** @description Operation resource name */
|
||||
name?: string;
|
||||
/** @description Whether the operation has completed */
|
||||
done?: boolean;
|
||||
/** @description The actual prediction response if done is true */
|
||||
/** @description The prediction response, present when done is true */
|
||||
response?: {
|
||||
/** @example type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse */
|
||||
"@type"?: string;
|
||||
/** @description Count of media filtered by responsible AI policies */
|
||||
/** @description Number of videos filtered by responsible AI policies */
|
||||
raiMediaFilteredCount?: number;
|
||||
/** @description Reasons why media was filtered by responsible AI policies */
|
||||
/** @description Reasons why videos were filtered by responsible AI policies */
|
||||
raiMediaFilteredReasons?: string[];
|
||||
videos?: {
|
||||
/** @description Cloud Storage URI of the video */
|
||||
/** @description Cloud Storage URI of the generated video */
|
||||
gcsUri?: string;
|
||||
/** @description Base64-encoded video content */
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Video MIME type */
|
||||
/** @description Video MIME type (video/mp4) */
|
||||
mimeType?: string;
|
||||
}[];
|
||||
};
|
||||
/** @description Error details if operation failed */
|
||||
/** @description Error details, present if the operation failed */
|
||||
error?: {
|
||||
/** @description Error code */
|
||||
/** @description gRPC error code */
|
||||
code?: number;
|
||||
/** @description Error message */
|
||||
message?: string;
|
||||
@@ -10653,8 +10772,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageGenerationRequest: {
|
||||
/**
|
||||
* @description The model to use for image generation
|
||||
* @example dall-e-3
|
||||
* @description The model to use for image generation (e.g., dall-e-2, dall-e-3, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
@@ -10721,8 +10840,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageEditRequest: {
|
||||
/**
|
||||
* @description The model to use for image editing
|
||||
* @example gpt-image-1
|
||||
* @description The model to use for image editing (e.g., dall-e-2, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model: string;
|
||||
/**
|
||||
@@ -15951,40 +16070,35 @@ export interface components {
|
||||
QuiverTextToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG generation
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
/** @description Text description of the desired SVG output */
|
||||
prompt: string;
|
||||
/** @description Additional style or formatting guidance */
|
||||
instructions?: string;
|
||||
/** @description Up to 4 reference images (URL or base64) */
|
||||
references?: components["schemas"]["QuiverImageObject"][];
|
||||
/** @description Optional reference images to guide style/composition. Accepts URL object, base64 object, or URL string shorthand. Runtime limits are model-specific. */
|
||||
references?: (components["schemas"]["QuiverImageObject"] | string)[];
|
||||
/**
|
||||
* @description Number of SVGs to generate
|
||||
* @default 1
|
||||
*/
|
||||
n: number;
|
||||
/**
|
||||
* @description Enable Server-Sent Events streaming
|
||||
* @default false
|
||||
*/
|
||||
stream: boolean;
|
||||
/**
|
||||
* @description Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -15992,42 +16106,37 @@ export interface components {
|
||||
QuiverImageToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG vectorization
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
image: components["schemas"]["QuiverImageObject"];
|
||||
/**
|
||||
* @description Automatically crop to dominant subject
|
||||
* @description Auto-crop image to the dominant subject before vectorization
|
||||
* @default false
|
||||
*/
|
||||
auto_crop: boolean;
|
||||
/** @description Square resize target in pixels */
|
||||
target_size?: number;
|
||||
/**
|
||||
* @description Number of SVGs to generate
|
||||
* @default 1
|
||||
*/
|
||||
n: number;
|
||||
/**
|
||||
* @description Enable Server-Sent Events streaming
|
||||
* @default false
|
||||
*/
|
||||
stream: boolean;
|
||||
/**
|
||||
* @description Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -16044,24 +16153,39 @@ export interface components {
|
||||
/** @description Response from Quiver AI SVG generation/vectorization */
|
||||
QuiverSVGResponse: {
|
||||
/** @description Unique identifier for the generation */
|
||||
id?: string;
|
||||
id: string;
|
||||
/** @description Unix timestamp of creation */
|
||||
created?: number;
|
||||
data?: {
|
||||
/** @description Generated SVG content */
|
||||
svg?: string;
|
||||
created: number;
|
||||
data: {
|
||||
/** @description Raw SVG markup */
|
||||
svg: string;
|
||||
/**
|
||||
* @description MIME type of the output
|
||||
* @default image/svg+xml
|
||||
* @enum {string}
|
||||
*/
|
||||
mime_type: string;
|
||||
mime_type: "image/svg+xml";
|
||||
}[];
|
||||
/** @description Credit cost for this request. Use this for billing instead of usage tokens. */
|
||||
credits?: number;
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Use credits for billing values.
|
||||
*/
|
||||
usage?: {
|
||||
/** @description Total tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
total_tokens?: number;
|
||||
/** @description Input tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
input_tokens?: number;
|
||||
/** @description Output tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
output_tokens?: number;
|
||||
};
|
||||
};
|
||||
@@ -27164,8 +27288,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID to use for generation */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -27219,8 +27343,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
@@ -357,4 +358,12 @@ describe('formatUtil', () => {
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCivitaiModelUrl', () => {
|
||||
it('recognizes civitai.red model URLs', () => {
|
||||
expect(
|
||||
isCivitaiModelUrl('https://civitai.red/api/download/models/123456')
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,9 +361,17 @@ export const generateUUID = (): string => {
|
||||
*/
|
||||
export const isCivitaiModelUrl = (url: string): boolean => {
|
||||
if (!isValidUrl(url)) return false
|
||||
if (!url.includes('civitai.com')) return false
|
||||
|
||||
const urlObj = new URL(url)
|
||||
const hostname = urlObj.hostname.toLowerCase()
|
||||
const isCivitaiHost =
|
||||
hostname === 'civitai.com' ||
|
||||
hostname.endsWith('.civitai.com') ||
|
||||
hostname === 'civitai.red' ||
|
||||
hostname.endsWith('.civitai.red')
|
||||
if (!isCivitaiHost) {
|
||||
return false
|
||||
}
|
||||
const pathname = urlObj.pathname
|
||||
|
||||
return (
|
||||
|
||||
10
src/base/common/selectionBounds.ts
Normal file
10
src/base/common/selectionBounds.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Padding applied around the selection bounding rect (both screen + canvas). */
|
||||
export const SELECTION_BOUNDS_PADDING = 10
|
||||
|
||||
/** Rectangle expressed in canvas-world coordinates. */
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
|
||||
<div
|
||||
ref="rootEl"
|
||||
data-testid="terminal-root"
|
||||
class="relative size-full overflow-hidden bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal size-full rounded-none p-2">
|
||||
<div ref="terminalEl" class="terminal-host h-full" />
|
||||
<div
|
||||
ref="terminalEl"
|
||||
data-testid="terminal-host"
|
||||
class="terminal-host h-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: tooltipText,
|
||||
showDelay: 300
|
||||
}"
|
||||
data-testid="terminal-copy-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:class="
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="size-full bg-transparent">
|
||||
<p v-if="errorMessage" class="p-4 text-center">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
data-testid="terminal-error-message"
|
||||
class="p-4 text-center"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<ProgressSpinner
|
||||
v-else-if="loading"
|
||||
data-testid="terminal-loading-spinner"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
|
||||
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
import WidgetBoundingBox from './WidgetBoundingBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ScrubableNumberInputStub = defineComponent({
|
||||
name: 'ScrubableNumberInput',
|
||||
props: {
|
||||
modelValue: { type: Number, default: 0 },
|
||||
min: { type: Number, default: 0 },
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:data-min="min"
|
||||
:data-step="step"
|
||||
@input="$emit('update:modelValue', Number(($event.target).value))"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function renderBox(initial: Bounds, disabled = false) {
|
||||
const value = ref<Bounds>(initial)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetBoundingBox },
|
||||
setup: () => ({ value, disabled }),
|
||||
template: '<WidgetBoundingBox v-model="value" :disabled="disabled" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { ScrubableNumberInput: ScrubableNumberInputStub }
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetBoundingBox', () => {
|
||||
describe('Label rendering', () => {
|
||||
it('renders labels for x, y, width, and height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 100, height: 100 })
|
||||
expect(screen.getByText('X')).toBeInTheDocument()
|
||||
expect(screen.getByText('Y')).toBeInTheDocument()
|
||||
expect(screen.getByText('Width')).toBeInTheDocument()
|
||||
expect(screen.getByText('Height')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial values', () => {
|
||||
it('displays the initial bounds across four inputs', () => {
|
||||
renderBox({ x: 10, y: 20, width: 300, height: 400 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
expect(inputs.map((i) => i.value)).toEqual(['10', '20', '300', '400'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constraints', () => {
|
||||
it('sets min=0 for x/y and min=1 for width/height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 })
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
expect(inputs[0].dataset.min).toBe('0') // x
|
||||
expect(inputs[1].dataset.min).toBe('0') // y
|
||||
expect(inputs[2].dataset.min).toBe('1') // width
|
||||
expect(inputs[3].dataset.min).toBe('1') // height
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model updates', () => {
|
||||
it('updates x immutably, preserving y/width/height', async () => {
|
||||
const { value } = renderBox({ x: 10, y: 20, width: 100, height: 200 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[0])
|
||||
await user.type(inputs[0], '55')
|
||||
expect(value.value).toEqual({
|
||||
x: 55,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('updates height immutably without mutating the original bounds', async () => {
|
||||
const initial = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const { value } = renderBox(initial)
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[3])
|
||||
await user.type(inputs[3], '500')
|
||||
expect(value.value.height).toBe(500)
|
||||
expect(initial).toEqual({ x: 10, y: 20, width: 100, height: 200 })
|
||||
expect(value.value).not.toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('disables all four inputs when disabled=true', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, true)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves all four inputs enabled when disabled=false', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, false)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).not.toBeDisabled()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
@@ -52,7 +53,7 @@
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-pay-amount">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
|
||||
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
import MultiSelectWidget from './MultiSelectWidget.vue'
|
||||
|
||||
const MultiSelectStub = defineComponent({
|
||||
name: 'MultiSelect',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
display: { type: String, default: '' }
|
||||
},
|
||||
template: `<div data-testid="multiselect"
|
||||
:data-options="JSON.stringify(options)"
|
||||
:data-placeholder="placeholder"
|
||||
:data-display="display"
|
||||
:data-model-value="JSON.stringify(modelValue)" />`
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
inputSpec: Partial<ComboInputSpec>
|
||||
): ComponentWidget<string[]> {
|
||||
return {
|
||||
name: 'multi',
|
||||
inputSpec: {
|
||||
type: 'COMBO',
|
||||
name: 'multi',
|
||||
...inputSpec
|
||||
} as ComboInputSpec
|
||||
} as unknown as ComponentWidget<string[]>
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
inputSpec: Partial<ComboInputSpec>,
|
||||
initialValue: string[] = []
|
||||
) {
|
||||
const value = ref<string[]>(initialValue)
|
||||
const widget = makeWidget(inputSpec)
|
||||
const Harness = defineComponent({
|
||||
components: { MultiSelectWidget },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<MultiSelectWidget v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: { plugins: [PrimeVue], stubs: { MultiSelect: MultiSelectStub } }
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('MultiSelectWidget', () => {
|
||||
describe('Option list', () => {
|
||||
it('passes inputSpec.options through as MultiSelect options', () => {
|
||||
renderWidget({ options: ['a', 'b', 'c'] })
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.options!)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('falls back to an empty list when inputSpec.options is absent', () => {
|
||||
renderWidget({})
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.options!)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Placeholder', () => {
|
||||
it('reads placeholder from multi_select.placeholder', () => {
|
||||
renderWidget({
|
||||
options: ['a'],
|
||||
multi_select: { placeholder: 'Pick one or more' }
|
||||
})
|
||||
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
|
||||
'Pick one or more'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults placeholder to "Select items" when not provided', () => {
|
||||
renderWidget({ options: ['a'] })
|
||||
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
|
||||
'Select items'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display mode', () => {
|
||||
it('uses "chip" display when multi_select.chip is true', () => {
|
||||
renderWidget({ options: ['a'], multi_select: { chip: true } })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('chip')
|
||||
})
|
||||
|
||||
it('uses "comma" display when chip is false or missing', () => {
|
||||
renderWidget({ options: ['a'], multi_select: { chip: false } })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
|
||||
})
|
||||
|
||||
it('uses "comma" display when multi_select is absent', () => {
|
||||
renderWidget({ options: ['a'] })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value binding', () => {
|
||||
it('forwards the initial selected items to MultiSelect', () => {
|
||||
renderWidget({ options: ['a', 'b'] }, ['a'])
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.modelValue!)).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
const execHolder = vi.hoisted(() => ({
|
||||
state: null as {
|
||||
executingNodeIds: Array<string | number>
|
||||
isIdle: boolean
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
execHolder.state = reactive({
|
||||
executingNodeIds: [] as Array<string | number>,
|
||||
isIdle: true
|
||||
})
|
||||
return {
|
||||
useExecutionStore: () => execHolder.state
|
||||
}
|
||||
})
|
||||
|
||||
const execState = (): {
|
||||
executingNodeIds: Array<string | number>
|
||||
isIdle: boolean
|
||||
} => execHolder.state!
|
||||
|
||||
import TextPreviewWidget from './TextPreviewWidget.vue'
|
||||
|
||||
const SkeletonStub = defineComponent({
|
||||
name: 'Skeleton',
|
||||
template: '<div data-testid="skeleton" />'
|
||||
})
|
||||
|
||||
function renderPreview(
|
||||
text: string,
|
||||
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
|
||||
) {
|
||||
const value = ref(text)
|
||||
const Harness = defineComponent({
|
||||
components: { TextPreviewWidget },
|
||||
setup: () => ({ value, nodeId }),
|
||||
template: '<TextPreviewWidget v-model="value" :node-id="nodeId" />'
|
||||
})
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
stubs: { Skeleton: SkeletonStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TextPreviewWidget', () => {
|
||||
beforeEach(() => {
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Text formatting', () => {
|
||||
it('renders plain text content', () => {
|
||||
const { container } = renderPreview('hello world')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('hello world')
|
||||
})
|
||||
|
||||
it('converts newlines to <br> tags', () => {
|
||||
const { container } = renderPreview('line1\nline2')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('<br')
|
||||
})
|
||||
|
||||
it('auto-links bare http URLs', () => {
|
||||
const { container } = renderPreview('visit https://example.com for info')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor?.getAttribute('href')).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bracketed link tokens [[label|url]]', () => {
|
||||
it('renders an http link with the supplied label', () => {
|
||||
const { container } = renderPreview(
|
||||
'see [[Docs|https://docs.example.com]]'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor?.getAttribute('href')).toBe('https://docs.example.com')
|
||||
expect(anchor?.textContent).toBe('Docs')
|
||||
})
|
||||
|
||||
it('sets target=_blank and rel=noopener for safety', () => {
|
||||
const { container } = renderPreview('[[Docs|https://x.example.com]]')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor?.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor?.getAttribute('rel')).toContain('noopener')
|
||||
})
|
||||
|
||||
it('renders label as plain text when url is not http(s)', () => {
|
||||
const { container } = renderPreview('[[Local|javascript:alert(1)]]')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('span')?.textContent).toContain('Local')
|
||||
})
|
||||
|
||||
it('escapes HTML in the label to prevent XSS', () => {
|
||||
const { container } = renderPreview(
|
||||
'[[<img src=x>|https://x.example.com]]'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('<img')
|
||||
expect(span?.innerHTML).not.toContain('<img src')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Raw HTML sanitisation in modelValue', () => {
|
||||
it('drops img tags entirely (strict allowlist is <a> + <br> only)', () => {
|
||||
const { container } = renderPreview('<img src=x onerror="alert(1)">')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const img = container.querySelector('img')
|
||||
expect(img).toBeNull()
|
||||
})
|
||||
|
||||
it('drops script tags from raw HTML in modelValue', () => {
|
||||
const { container } = renderPreview(
|
||||
'hello<script>window.__xss = true</script>world'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops iframe tags', () => {
|
||||
const { container } = renderPreview(
|
||||
'<iframe src="https://evil.example.com"></iframe>'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips inline javascript: hrefs on anchors', () => {
|
||||
const { container } = renderPreview(
|
||||
'<a href="javascript:alert(1)">click</a>'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
const href = anchor?.getAttribute('href')
|
||||
expect(href == null || !href.startsWith('javascript:')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the <br> tag produced by nl2br', () => {
|
||||
const { container } = renderPreview('line1\nline2')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('br')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Execution state', () => {
|
||||
it('hides the Skeleton on mount when execution is already idle', () => {
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a Skeleton on mount when the parent node is executing', () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Skeleton when execution transitions to idle', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
|
||||
execState().executingNodeIds = ['other']
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -25,7 +26,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const isParentNodeExecuting = computed(() => {
|
||||
if (executionStore.isIdle) return false
|
||||
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
|
||||
return executionStore.executingNodeIds.includes(parentNodeId)
|
||||
})
|
||||
const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
|
||||
@@ -51,39 +56,27 @@ const formattedText = computed(() => {
|
||||
: safeLabel
|
||||
})
|
||||
|
||||
return html
|
||||
// Strict allowlist: this widget only needs anchors and line breaks. Raw
|
||||
// websocket progress text flows into modelValue, so we drop every other
|
||||
// tag (img, script, iframe, etc.) to keep the v-html trust boundary tight.
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['a', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel']
|
||||
})
|
||||
})
|
||||
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
parentNodeId = props.nodeId ?? parentNodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
const stopWatching = watch(
|
||||
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
|
||||
() => {
|
||||
if (executionStore.isIdle) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent node is no longer in the executing nodes list
|
||||
if (
|
||||
parentNodeId &&
|
||||
!executionStore.executingNodeIds.includes(parentNodeId)
|
||||
) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
}
|
||||
|
||||
// Set parent node ID if not set yet
|
||||
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
|
||||
parentNodeId = executionStore.executingNodeIds[0]
|
||||
}
|
||||
// Lazily adopt the first executing node as the parent when no nodeId is known.
|
||||
watch(
|
||||
() => executionStore.executingNodeIds,
|
||||
(ids) => {
|
||||
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
type="button"
|
||||
class="help-menu-item"
|
||||
:class="{ 'more-item': menuItem.key === 'more' }"
|
||||
:data-testid="`help-menu-item-${menuItem.key}`"
|
||||
role="menuitem"
|
||||
@click="menuItem.action"
|
||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||
@@ -103,6 +104,7 @@
|
||||
v-for="release in releaseStore.recentReleases"
|
||||
:key="release.id || release.version"
|
||||
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded-sm p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
|
||||
:data-testid="`help-release-item-${release.version}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onReleaseClick(release)"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
data-testid="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
@@ -38,6 +39,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
data-testid="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
246
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
246
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const cropHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
function createDefaultCropState() {
|
||||
return {
|
||||
imageUrl: ref<string | null>(null),
|
||||
isLoading: ref(false),
|
||||
selectedRatio: ref('1:1'),
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
handleResizeStart: () => {},
|
||||
handleResizeMove: () => {},
|
||||
handleResizeEnd: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useImageCrop', async () => {
|
||||
return {
|
||||
ASPECT_RATIOS: {
|
||||
'1:1': 1,
|
||||
'4:3': 4 / 3,
|
||||
custom: null
|
||||
},
|
||||
useImageCrop: () => {
|
||||
if (!cropHolder.state) {
|
||||
cropHolder.state = createDefaultCropState()
|
||||
}
|
||||
return cropHolder.state
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
boundsExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
import WidgetImageCrop from './WidgetImageCrop.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
imageCrop: {
|
||||
loading: 'Loading...',
|
||||
noInputImage: 'No input image connected',
|
||||
cropPreviewAlt: 'Crop preview',
|
||||
ratio: 'Ratio',
|
||||
lockRatio: 'Lock aspect ratio',
|
||||
unlockRatio: 'Unlock aspect ratio',
|
||||
custom: 'Custom'
|
||||
},
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const Passthrough = defineComponent({
|
||||
template: '<div><slot /></div>'
|
||||
})
|
||||
|
||||
const WidgetBoundingBoxStub = defineComponent({
|
||||
name: 'WidgetBoundingBox',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `<div data-testid="bbox-child"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-model="JSON.stringify(modelValue)"
|
||||
@click="$emit('update:modelValue', { x: 1, y: 2, width: 3, height: 4 })"
|
||||
/>`
|
||||
})
|
||||
|
||||
function primeCropState(overrides: Record<string, unknown> = {}) {
|
||||
cropHolder.state = {
|
||||
...createDefaultCropState(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<SimplifiedWidget<Bounds>> = {}
|
||||
): SimplifiedWidget<Bounds> {
|
||||
return {
|
||||
name: 'crop',
|
||||
type: 'imagecrop',
|
||||
value: { x: 0, y: 0, width: 512, height: 512 },
|
||||
options: {},
|
||||
...overrides
|
||||
} as SimplifiedWidget<Bounds>
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<Bounds> = makeWidget(),
|
||||
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
|
||||
) {
|
||||
const value = ref<Bounds>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetImageCrop },
|
||||
setup: () => ({ value, widget }),
|
||||
template:
|
||||
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Select: Passthrough,
|
||||
SelectContent: Passthrough,
|
||||
SelectTrigger: Passthrough,
|
||||
SelectValue: Passthrough,
|
||||
SelectItem: Passthrough,
|
||||
WidgetBoundingBox: WidgetBoundingBoxStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetImageCrop', () => {
|
||||
beforeEach(() => {
|
||||
cropHolder.state = null
|
||||
upstreamHolder.ref = null
|
||||
})
|
||||
|
||||
describe('Image states', () => {
|
||||
it('shows the empty-state placeholder when imageUrl is null', () => {
|
||||
primeCropState()
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument()
|
||||
expect(screen.getByText('No input image connected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the loading message when isLoading is true', () => {
|
||||
primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') })
|
||||
renderWidget()
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('crop-empty-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an img when imageUrl is set and not loading', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
renderWidget()
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'Crop preview' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading...')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the crop overlay when an image is loaded', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-overlay')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('hides the ratio controls when widget is disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.queryByText('Ratio')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the ratio controls when widget is enabled', () => {
|
||||
renderWidget()
|
||||
expect(screen.getByText('Ratio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes disabled=true to the bounding box child when disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bounds delegation', () => {
|
||||
it('forwards v-model to the bounding box child', () => {
|
||||
renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 })
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 })
|
||||
})
|
||||
|
||||
it('updates v-model when the bounding box emits a change', async () => {
|
||||
const { value } = renderWidget()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('bbox-child'))
|
||||
expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 })
|
||||
})
|
||||
|
||||
it('uses upstream bounds when disabled and upstream is available', () => {
|
||||
upstreamHolder.ref = ref<unknown>({
|
||||
x: 7,
|
||||
y: 8,
|
||||
width: 20,
|
||||
height: 30
|
||||
})
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
}),
|
||||
{ x: 0, y: 0, width: 512, height: 512 }
|
||||
)
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
185
src/components/range/WidgetRange.test.ts
Normal file
185
src/components/range/WidgetRange.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as { value: unknown } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
singleValueExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const outputsHolder = vi.hoisted(() => ({
|
||||
nodeOutputs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => outputsHolder
|
||||
}))
|
||||
|
||||
import WidgetRange from './WidgetRange.vue'
|
||||
|
||||
const RangeEditorStub = defineComponent({
|
||||
name: 'RangeEditor',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({ min: 0, max: 1 }) },
|
||||
disabled: { type: Boolean, default: false },
|
||||
histogram: { type: Object, default: null },
|
||||
display: { type: String, default: '' }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="range-editor"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-has-histogram="String(!!histogram)"
|
||||
:data-model="JSON.stringify(modelValue)"
|
||||
:data-display="display"
|
||||
@click="$emit('update:modelValue', { min: 5, max: 10 })"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
options: Partial<IWidgetRangeOptions> = {},
|
||||
widgetOverrides: Partial<
|
||||
SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
> = {}
|
||||
): SimplifiedWidget<RangeValue, IWidgetRangeOptions> {
|
||||
return {
|
||||
name: 'range_w',
|
||||
type: 'range',
|
||||
value: { min: 0, max: 1 },
|
||||
options: options as IWidgetRangeOptions,
|
||||
...widgetOverrides
|
||||
} as SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}
|
||||
|
||||
function setUpstream(value: RangeValue | undefined) {
|
||||
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
|
||||
upstreamHolder.ref.value = value
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>,
|
||||
initialModel: RangeValue = { min: 0, max: 1 }
|
||||
) {
|
||||
const value = ref<RangeValue>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetRange },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<WidgetRange v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: { stubs: { RangeEditor: RangeEditorStub } }
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetRange', () => {
|
||||
beforeEach(() => {
|
||||
upstreamHolder.ref = null
|
||||
outputsHolder.nodeOutputs = {}
|
||||
})
|
||||
|
||||
describe('Value pass-through', () => {
|
||||
it('forwards modelValue to the RangeEditor', () => {
|
||||
renderWidget(makeWidget(), { min: 0.2, max: 0.8 })
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.2, max: 0.8 })
|
||||
})
|
||||
|
||||
it('propagates editor updates back to v-model', async () => {
|
||||
const { value } = renderWidget(makeWidget())
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('range-editor'))
|
||||
expect(value.value).toEqual({ min: 5, max: 10 })
|
||||
})
|
||||
|
||||
it('forwards the display option to the RangeEditor', () => {
|
||||
renderWidget(makeWidget({ display: 'histogram' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.display).toBe(
|
||||
'histogram'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('passes disabled=true when widget.options.disabled is set', () => {
|
||||
renderWidget(makeWidget({ disabled: true }))
|
||||
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('true')
|
||||
})
|
||||
|
||||
it('passes disabled=false by default', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('false')
|
||||
})
|
||||
|
||||
it('shows upstream value when disabled with a valid upstream', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(
|
||||
makeWidget({ disabled: true } as IWidgetRangeOptions, {
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
}),
|
||||
{ min: 0, max: 1 }
|
||||
)
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.3, max: 0.7 })
|
||||
})
|
||||
|
||||
it('ignores upstream value when not disabled', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
|
||||
min: 0,
|
||||
max: 1
|
||||
})
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Histogram', () => {
|
||||
it('passes null histogram when nodeLocatorId is absent', () => {
|
||||
renderWidget(makeWidget())
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a histogram when node output has a matching histogram entry', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an empty histogram array as null', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
|
||||
}
|
||||
}))
|
||||
}))
|
||||
@@ -284,7 +284,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
|
||||
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
telemetry: null as {
|
||||
trackUiButtonClicked: ReturnType<typeof vi.fn>
|
||||
trackHelpResourceClicked: ReturnType<typeof vi.fn>
|
||||
} | null,
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mocks.execute
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: mocks.staticUrls
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => mocks.telemetry
|
||||
}))
|
||||
|
||||
describe('useErrorActions', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.telemetry = {
|
||||
trackUiButtonClicked: mocks.trackUiButtonClicked,
|
||||
trackHelpResourceClicked: mocks.trackHelpResourceClicked
|
||||
}
|
||||
mocks.trackUiButtonClicked.mockReset()
|
||||
mocks.trackHelpResourceClicked.mockReset()
|
||||
mocks.execute.mockReset()
|
||||
windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null as unknown as Window)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('openGitHubIssues', () => {
|
||||
it('tracks the button click and opens the GitHub issues URL in a new tab', () => {
|
||||
const { openGitHubIssues } = useErrorActions()
|
||||
|
||||
openGitHubIssues()
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
mocks.staticUrls.githubIssues,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('still opens the link when telemetry is unavailable', () => {
|
||||
mocks.telemetry = null
|
||||
const { openGitHubIssues } = useErrorActions()
|
||||
|
||||
openGitHubIssues()
|
||||
|
||||
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
mocks.staticUrls.githubIssues,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contactSupport', () => {
|
||||
it('tracks the help resource click and executes the contact support command', () => {
|
||||
mocks.execute.mockReturnValue('executed')
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
const result = contactSupport()
|
||||
|
||||
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(result).toBe('executed')
|
||||
})
|
||||
|
||||
it('returns the execute promise when the command is async', async () => {
|
||||
mocks.execute.mockResolvedValue('done')
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
await expect(contactSupport()).resolves.toBe('done')
|
||||
})
|
||||
|
||||
it('still executes the command when telemetry is unavailable', () => {
|
||||
mocks.telemetry = null
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
void contactSupport()
|
||||
|
||||
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
|
||||
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findOnGitHub', () => {
|
||||
it('tracks the click and opens a URL-encoded issue search with " is:issue" appended', () => {
|
||||
const { findOnGitHub } = useErrorActions()
|
||||
|
||||
findOnGitHub('CUDA out of memory')
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
`${mocks.staticUrls.githubIssues}?q=${expectedQuery}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('URL-encodes messages with special characters', () => {
|
||||
const { findOnGitHub } = useErrorActions()
|
||||
|
||||
findOnGitHub('error with spaces & symbols?')
|
||||
|
||||
const [[url]] = windowOpenSpy.mock.calls as unknown as [[string]]
|
||||
expect(url).toContain('?q=')
|
||||
const queryPart = url.split('?q=')[1]
|
||||
expect(decodeURIComponent(queryPart)).toBe(
|
||||
'error with spaces & symbols? is:issue'
|
||||
)
|
||||
})
|
||||
|
||||
it('still opens the link when telemetry is unavailable', () => {
|
||||
mocks.telemetry = null
|
||||
const { findOnGitHub } = useErrorActions()
|
||||
|
||||
findOnGitHub('boom')
|
||||
|
||||
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
|
||||
expect(windowOpenSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
// Helpers only — imports happen inside factories below.
|
||||
return {
|
||||
getLogs: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
refetchSystemStats: vi.fn(),
|
||||
generateErrorReport: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const storeState = vi.hoisted(() => {
|
||||
// Plain objects wired up in beforeEach. Tests use setStoreState to swap values.
|
||||
return {
|
||||
systemStats: null as unknown,
|
||||
isLoading: false
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: mocks.getLogs
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: mocks.serialize
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/errorReportUtil', () => ({
|
||||
generateErrorReport: mocks.generateErrorReport
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', async () => {
|
||||
const { ref: vueRef } = await import('vue')
|
||||
const systemStatsRef = vueRef<unknown>(null)
|
||||
const isLoadingRef = vueRef(false)
|
||||
|
||||
return {
|
||||
useSystemStatsStore: () => ({
|
||||
get systemStats() {
|
||||
return systemStatsRef.value
|
||||
},
|
||||
set systemStats(value: unknown) {
|
||||
systemStatsRef.value = value
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoadingRef.value
|
||||
},
|
||||
set isLoading(value: boolean) {
|
||||
isLoadingRef.value = value
|
||||
},
|
||||
refetchSystemStats: mocks.refetchSystemStats,
|
||||
__setSystemStats(value: unknown) {
|
||||
systemStatsRef.value = value
|
||||
},
|
||||
__setIsLoading(value: boolean) {
|
||||
isLoadingRef.value = value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type TestStore = ReturnType<typeof useSystemStatsStore> & {
|
||||
__setSystemStats: (value: unknown) => void
|
||||
__setIsLoading: (value: boolean) => void
|
||||
}
|
||||
|
||||
async function getStore(): Promise<TestStore> {
|
||||
const mod = await import('@/stores/systemStatsStore')
|
||||
return mod.useSystemStatsStore() as unknown as TestStore
|
||||
}
|
||||
|
||||
const sampleSystemStats = {
|
||||
system: {
|
||||
os: 'Linux',
|
||||
comfyui_version: '1.0.0',
|
||||
argv: [],
|
||||
python_version: '3.11',
|
||||
embedded_python: false,
|
||||
pytorch_version: '2.3.0'
|
||||
},
|
||||
devices: []
|
||||
}
|
||||
|
||||
function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: '42',
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useErrorReport', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.getLogs.mockReset()
|
||||
mocks.serialize.mockReset()
|
||||
mocks.refetchSystemStats.mockReset()
|
||||
mocks.generateErrorReport.mockReset()
|
||||
storeState.systemStats = null
|
||||
storeState.isLoading = false
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(null)
|
||||
store.__setIsLoading(false)
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns early without enrichment when the card has no runtime errors', async () => {
|
||||
const card = makeCard({
|
||||
errors: [{ message: 'static', details: 'details' }]
|
||||
})
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.getLogs).not.toHaveBeenCalled()
|
||||
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'details' })
|
||||
})
|
||||
|
||||
it('enriches each runtime error with a generated report when systemStats is present', async () => {
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
mocks.getLogs.mockResolvedValue('server logs')
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockImplementation(
|
||||
({ exceptionType }: { exceptionType: string }) =>
|
||||
`report:${exceptionType}`
|
||||
)
|
||||
|
||||
const card = makeCard({
|
||||
errors: [
|
||||
{
|
||||
message: 'CUDA oom',
|
||||
details: 'trace-0',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
},
|
||||
{
|
||||
message: 'static',
|
||||
details: 'skip-me'
|
||||
},
|
||||
{
|
||||
message: 'Other runtime error',
|
||||
details: 'trace-2',
|
||||
isRuntimeError: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(1, {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: '42',
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
workflow: { nodes: [] }
|
||||
})
|
||||
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
exceptionType: 'Runtime Error',
|
||||
exceptionMessage: 'Other runtime error',
|
||||
traceback: 'trace-2'
|
||||
})
|
||||
)
|
||||
|
||||
expect(displayedDetailsMap.value).toEqual({
|
||||
0: 'report:RuntimeError',
|
||||
1: 'skip-me',
|
||||
2: 'report:Runtime Error'
|
||||
})
|
||||
})
|
||||
|
||||
it('awaits the systemStats loading flag before proceeding', async () => {
|
||||
const store = await getStore()
|
||||
store.__setIsLoading(true)
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockReturnValue('report')
|
||||
|
||||
const card = makeCard({
|
||||
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
|
||||
})
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.getLogs).not.toHaveBeenCalled()
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
|
||||
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
store.__setIsLoading(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'report' })
|
||||
})
|
||||
|
||||
it('calls refetchSystemStats when not loading and stats are missing', async () => {
|
||||
const store = await getStore()
|
||||
mocks.refetchSystemStats.mockImplementation(async () => {
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
})
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockReturnValue('report')
|
||||
|
||||
const card = makeCard({
|
||||
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
|
||||
})
|
||||
|
||||
useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns early and warns when refetchSystemStats throws', async () => {
|
||||
mocks.refetchSystemStats.mockRejectedValue(new Error('boom'))
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
|
||||
const card = makeCard({
|
||||
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
|
||||
})
|
||||
|
||||
useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getLogs).not.toHaveBeenCalled()
|
||||
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns early and warns when workflow serialization throws', async () => {
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
mocks.serialize.mockImplementation(() => {
|
||||
throw new Error('serialize failed')
|
||||
})
|
||||
|
||||
const card = makeCard({
|
||||
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
|
||||
})
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
|
||||
})
|
||||
|
||||
it('falls back to original error.details when generateErrorReport throws', async () => {
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockImplementation(() => {
|
||||
throw new Error('generate failed')
|
||||
})
|
||||
|
||||
const card = makeCard({
|
||||
errors: [
|
||||
{ message: 'runtime', details: 'fallback', isRuntimeError: true }
|
||||
]
|
||||
})
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'fallback' })
|
||||
})
|
||||
|
||||
it('re-enriches and clears stale enriched details when the card ref changes', async () => {
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
mocks.getLogs.mockResolvedValue('logs')
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockImplementation(
|
||||
({ exceptionMessage }: { exceptionMessage: string }) =>
|
||||
`report:${exceptionMessage}`
|
||||
)
|
||||
|
||||
const cardRef = ref<ErrorCardData>(
|
||||
makeCard({
|
||||
id: 'first',
|
||||
errors: [
|
||||
{ message: 'first-err', details: 'first', isRuntimeError: true }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(cardRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'report:first-err' })
|
||||
|
||||
cardRef.value = makeCard({
|
||||
id: 'second',
|
||||
errors: [{ message: 'plain', details: 'plain-details' }]
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'plain-details' })
|
||||
})
|
||||
|
||||
it('drops stale results when the card changes mid-flight', async () => {
|
||||
const store = await getStore()
|
||||
store.__setSystemStats(sampleSystemStats)
|
||||
mocks.serialize.mockReturnValue({ nodes: [] })
|
||||
mocks.generateErrorReport.mockImplementation(
|
||||
({ exceptionMessage }: { exceptionMessage: string }) =>
|
||||
`report:${exceptionMessage}`
|
||||
)
|
||||
|
||||
const firstLogsDeferred: {
|
||||
resolve: (value: string) => void
|
||||
promise: Promise<string>
|
||||
} = (() => {
|
||||
let resolve: (value: string) => void = () => {}
|
||||
const promise = new Promise<string>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { resolve, promise }
|
||||
})()
|
||||
mocks.getLogs.mockImplementationOnce(() => firstLogsDeferred.promise)
|
||||
mocks.getLogs.mockImplementationOnce(async () => 'second-logs')
|
||||
|
||||
const cardRef = ref<ErrorCardData>(
|
||||
makeCard({
|
||||
id: 'first',
|
||||
errors: [
|
||||
{ message: 'first-err', details: 'first', isRuntimeError: true }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { displayedDetailsMap } = useErrorReport(cardRef)
|
||||
await flushPromises()
|
||||
|
||||
cardRef.value = makeCard({
|
||||
id: 'second',
|
||||
errors: [
|
||||
{ message: 'second-err', details: 'second', isRuntimeError: true }
|
||||
]
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
firstLogsDeferred.resolve('stale-logs')
|
||||
await flushPromises()
|
||||
|
||||
expect(displayedDetailsMap.value).toEqual({ 0: 'report:second-err' })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, fireEvent } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -90,7 +90,6 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
default: defineComponent({
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
emits: ['close'],
|
||||
@@ -133,7 +132,6 @@ describe('CurrentUserButton', () => {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
Popover: defineComponent({
|
||||
setup(_, { slots, expose }) {
|
||||
const shown = ref(false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
@@ -103,11 +103,13 @@ vi.mock('@/stores/authStore', () => ({
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTierName: { value: 'Creator' },
|
||||
subscriptionTier: { value: 'CREATOR' },
|
||||
isActiveSubscription: ref(true),
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
@@ -188,6 +190,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
@@ -406,14 +409,43 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud free tier', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockIsFreeTier.value = true
|
||||
})
|
||||
|
||||
it('shows upgrade-to-add-credits button and hides add-credits button', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByTestId('upgrade-to-add-credits-button')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-cloud distribution', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
it('hides credits section', () => {
|
||||
it('still shows credits balance', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows add-credits button and hides upgrade-to-add-credits button', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides upgrade-to-add-credits button even when on free tier', () => {
|
||||
mockIsFreeTier.value = true
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -424,11 +456,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item', () => {
|
||||
it('still shows partner nodes menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('partner-nodes-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides plans & pricing menu item', () => {
|
||||
@@ -438,11 +468,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides manage plan menu item', () => {
|
||||
it('still shows manage plan menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows user settings menu item', () => {
|
||||
|
||||
@@ -29,11 +29,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section (cloud only) -->
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
@@ -49,7 +46,7 @@
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@@ -82,7 +79,7 @@
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="isActiveSubscription"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
@@ -112,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="isActiveSubscription"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
|
||||
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
function createDragEvent(options: {
|
||||
items?: Array<{ kind: string }>
|
||||
files?: File[]
|
||||
types?: string[]
|
||||
uri?: string
|
||||
}): DragEvent {
|
||||
const { items = [], files = [], types = [], uri = '' } = options
|
||||
return fromAny<DragEvent, unknown>({
|
||||
dataTransfer: {
|
||||
items: fromAny<DataTransferItemList, unknown>(items),
|
||||
files: fromAny<FileList, unknown>(files),
|
||||
types,
|
||||
getData: vi.fn((format: string) =>
|
||||
format === 'text/uri-list' ? uri : ''
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodeDragAndDrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('onDragOver detects file items by default', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(isDragging).toBe(true)
|
||||
})
|
||||
|
||||
it('onDragOver delegates to custom handler result', () => {
|
||||
const node = createNode()
|
||||
const onDragOver = vi.fn().mockReturnValue(false)
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop: vi.fn().mockResolvedValue([]),
|
||||
onDragOver
|
||||
})
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(onDragOver).toHaveBeenCalledTimes(1)
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
it('onDragOver returns true for uri list drops without file items', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'string' }], types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(isDragging).toBe(true)
|
||||
})
|
||||
|
||||
it('onDragOver returns false when drag event has no items', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(fromAny<DragEvent, unknown>({}))
|
||||
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
it('onDragDrop calls onDrop with filtered files', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ files: [keep, skip], items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onDrop).toHaveBeenCalledWith([keep])
|
||||
})
|
||||
|
||||
it('onDragDrop returns false for invalid drops', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(createDragEvent({}))
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop handles same-origin uri drops', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
|
||||
})
|
||||
)
|
||||
const uri = `${location.origin}/api/file?filename=uri.png`
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fetchSpy).toHaveBeenCalledWith(new URL(uri))
|
||||
expect(onDrop).toHaveBeenCalledTimes(1)
|
||||
expect(onDrop.mock.calls[0][0][0]).toBeInstanceOf(File)
|
||||
expect(onDrop.mock.calls[0][0][0].name).toBe('uri.png')
|
||||
})
|
||||
|
||||
it('onDragDrop returns false for cross-origin uri drops', async () => {
|
||||
const node = createNode()
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({
|
||||
uri: 'https://example.com/api/file?filename=uri.png',
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop returns false when uri fetch throws', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'))
|
||||
const uri = `${location.origin}/api/file?filename=uri.png`
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop returns false when uri response is invalid or filtered out', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const uri = `${location.origin}/api/file?filename=uri.jpg`
|
||||
|
||||
const nodeA = createNode()
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
fromAny<Response, unknown>({ ok: false })
|
||||
)
|
||||
useNodeDragAndDrop(nodeA, { onDrop })
|
||||
const badResponseResult = await nodeA.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
expect(badResponseResult).toBe(false)
|
||||
|
||||
const nodeB = createNode()
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/jpeg' }))
|
||||
})
|
||||
)
|
||||
useNodeDragAndDrop(nodeB, {
|
||||
onDrop,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
const filteredOutResult = await nodeB.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(filteredOutResult).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onRemoved clears handlers and chains existing onRemoved', () => {
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
expect(node.onDragOver).toBeTypeOf('function')
|
||||
expect(node.onDragDrop).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(node.onDragOver).toBeUndefined()
|
||||
expect(node.onDragDrop).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onRemoved preserves handlers replaced by another extension', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const replacementDragOver = vi.fn()
|
||||
const replacementDragDrop = vi.fn()
|
||||
node.onDragOver = replacementDragOver
|
||||
node.onDragDrop = replacementDragDrop
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(node.onDragOver).toBe(replacementDragOver)
|
||||
expect(node.onDragDrop).toBe(replacementDragDrop)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type DragHandler = (e: DragEvent) => boolean
|
||||
@@ -43,9 +44,10 @@ export const useNodeDragAndDrop = <T>(
|
||||
return !!e?.dataTransfer?.getData('text/uri-list')
|
||||
}
|
||||
|
||||
node.onDragOver = isDraggingFiles
|
||||
const installedDragOver = isDraggingFiles
|
||||
node.onDragOver = installedDragOver
|
||||
|
||||
node.onDragDrop = async function (e: DragEvent) {
|
||||
const installedDragDrop = async function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
@@ -73,4 +75,10 @@ export const useNodeDragAndDrop = <T>(
|
||||
}
|
||||
return true
|
||||
}
|
||||
node.onDragDrop = installedDragDrop
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
if (node.onDragOver === installedDragOver) node.onDragOver = undefined
|
||||
if (node.onDragDrop === installedDragDrop) node.onDragDrop = undefined
|
||||
})
|
||||
}
|
||||
|
||||
175
src/composables/node/useNodeFileInput.test.ts
Normal file
175
src/composables/node/useNodeFileInput.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeFileInput } from './useNodeFileInput'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
function setInputFiles(input: HTMLInputElement, files: File[]) {
|
||||
Object.defineProperty(input, 'files', {
|
||||
configurable: true,
|
||||
value: fromAny<FileList, unknown>(files)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
Object.defineProperty(input, 'value', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodeFileInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates a file input with configured attributes and defaults', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('input')
|
||||
expect(fileInput.type).toBe('file')
|
||||
expect(fileInput.accept).toBe('*')
|
||||
expect(fileInput.multiple).toBe(false)
|
||||
})
|
||||
|
||||
it('uses provided accept and allow_batch options', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
useNodeFileInput(node, {
|
||||
onSelect: vi.fn(),
|
||||
accept: 'image/*',
|
||||
allow_batch: true
|
||||
})
|
||||
|
||||
expect(fileInput.accept).toBe('image/*')
|
||||
expect(fileInput.multiple).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onSelect with filtered files and resets value on change', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodeFileInput(node, {
|
||||
onSelect,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
setInputFiles(fileInput, [keep, skip])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\keep.png')
|
||||
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith([keep])
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not call onSelect for empty file list and still resets value', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
|
||||
useNodeFileInput(node, { onSelect })
|
||||
|
||||
setInputFiles(fileInput, [])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\empty.png')
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('resets value before invoking onSelect so it is cleared even on throw', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
const onSelect = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
useNodeFileInput(node, { onSelect })
|
||||
|
||||
setInputFiles(fileInput, [createFile('test.png')])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\test.png')
|
||||
|
||||
expect(() => fileInput.onchange?.(new Event('change'))).toThrow('boom')
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not call onSelect when all files are filtered out', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
|
||||
useNodeFileInput(node, {
|
||||
onSelect,
|
||||
fileFilter: () => false
|
||||
})
|
||||
|
||||
setInputFiles(fileInput, [createFile('ignored.png')])
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('openFileSelection clicks the generated input', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
openFileSelection()
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cleans up on removal, chains existing callback, and no-ops after removal', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
expect(fileInput.onchange).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(fileInput.onchange).toBeNull()
|
||||
|
||||
openFileSelection()
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,12 @@ export function useNodeFileInput(node: LGraphNode, options: FileInputOptions) {
|
||||
fileInput.multiple = allow_batch
|
||||
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput?.files?.length) {
|
||||
const files = Array.from(fileInput.files).filter(fileFilter)
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
const files = fileInput?.files?.length
|
||||
? Array.from(fileInput.files).filter(fileFilter)
|
||||
: []
|
||||
// Reset value so re-selecting the same file triggers onchange
|
||||
if (fileInput) fileInput.value = ''
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
|
||||
107
src/composables/node/useNodePaste.test.ts
Normal file
107
src/composables/node/useNodePaste.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodePaste } from './useNodePaste'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
describe('useNodePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('pasteFiles calls onPaste with filtered files', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodePaste(node, {
|
||||
onPaste,
|
||||
fileFilter: (file) => file.type === 'image/png',
|
||||
allow_batch: true
|
||||
})
|
||||
|
||||
const result = node.pasteFiles?.([keep, skip])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([keep])
|
||||
})
|
||||
|
||||
it('pasteFiles returns false when no files match filter', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
|
||||
useNodePaste(node, {
|
||||
onPaste,
|
||||
fileFilter: () => false
|
||||
})
|
||||
|
||||
const result = node.pasteFiles?.([createFile('ignored.png')])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onPaste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pasteFiles limits to first file when allow_batch is false', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const first = createFile('first.png')
|
||||
const second = createFile('second.png')
|
||||
|
||||
useNodePaste(node, { onPaste, allow_batch: false })
|
||||
|
||||
const result = node.pasteFiles?.([first, second])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([first])
|
||||
})
|
||||
|
||||
it('pasteFiles passes all files when allow_batch is true', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const first = createFile('first.png')
|
||||
const second = createFile('second.png')
|
||||
|
||||
useNodePaste(node, { onPaste, allow_batch: true })
|
||||
|
||||
const result = node.pasteFiles?.([first, second])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([first, second])
|
||||
})
|
||||
|
||||
it('onRemoved clears pasteFiles and chains existing onRemoved', () => {
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
|
||||
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
|
||||
expect(node.pasteFiles).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(node.pasteFiles).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onRemoved preserves pasteFiles replaced by another extension', () => {
|
||||
const node = createNode()
|
||||
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
|
||||
|
||||
const replacementPasteFiles = vi.fn()
|
||||
node.pasteFiles = replacementPasteFiles
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(node.pasteFiles).toBe(replacementPasteFiles)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type PasteHandler<T> = (files: File[]) => Promise<T>
|
||||
@@ -17,7 +18,7 @@ export const useNodePaste = <T>(
|
||||
) => {
|
||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||
|
||||
node.pasteFiles = function (files: File[]) {
|
||||
const installedPasteFiles = function (files: File[]) {
|
||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||
if (!filteredFiles.length) return false
|
||||
|
||||
@@ -26,4 +27,9 @@ export const useNodePaste = <T>(
|
||||
void onPaste(paste)
|
||||
return true
|
||||
}
|
||||
node.pasteFiles = installedPasteFiles
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
if (node.pasteFiles === installedPasteFiles) node.pasteFiles = undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,11 +43,9 @@ describe('useExternalLink', () => {
|
||||
|
||||
// Static URLs
|
||||
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
|
||||
expect(staticUrls.github).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI'
|
||||
)
|
||||
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
|
||||
expect(staticUrls.githubIssues).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
'https://github.com/Comfy-Org/ComfyUI/issues'
|
||||
)
|
||||
expect(staticUrls.githubFrontend).toBe(
|
||||
'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
|
||||
@@ -85,8 +85,8 @@ export function useExternalLink() {
|
||||
const staticUrls = {
|
||||
// Static external URLs
|
||||
discord: 'https://www.comfy.org/discord',
|
||||
github: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
@@ -321,6 +321,39 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves existing node callbacks through initializeLoad3d', () => {
|
||||
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
|
||||
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
|
||||
// unregisters the component widget from the DOM widget store. If
|
||||
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
|
||||
// cleanup is lost and the interactive UI persists with a stale reference.
|
||||
it('chains node.onRemoved with a preexisting callback', async () => {
|
||||
const existingOnRemoved = vi.fn()
|
||||
mockNode.onRemoved = existingOnRemoved
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('chains node.onResize with a preexisting callback', async () => {
|
||||
const existingOnResize = vi.fn()
|
||||
mockNode.onResize = existingOnResize
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onResize?.([512, 512] as Size)
|
||||
|
||||
expect(existingOnResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForLoad3d', () => {
|
||||
it('should execute callback immediately if Load3d exists', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
@@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
}
|
||||
})
|
||||
|
||||
node.onMouseLeave = function () {
|
||||
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
}
|
||||
})
|
||||
|
||||
node.onResize = function () {
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
load3d?.handleResize()
|
||||
}
|
||||
})
|
||||
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
node.onDrawBackground = useChainCallback(
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
node.onRemoved = function () {
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
}
|
||||
})
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
|
||||
@@ -323,6 +323,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
changeOutputType(this, output, outputType)
|
||||
})
|
||||
// Force Vue reactivity update for output slot types.
|
||||
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
|
||||
// so mutating output.type alone doesn't trigger re-render.
|
||||
this.outputs = [...this.outputs]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
699
src/extensions/core/load3d/SceneModelManager.test.ts
Normal file
699
src/extensions/core/load3d/SceneModelManager.test.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
|
||||
function createMockRenderer(): THREE.WebGLRenderer {
|
||||
return {
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
dispose: vi.fn()
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
function createMockEventManager(): EventManagerInterface {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createManager(
|
||||
overrides: {
|
||||
scene?: THREE.Scene
|
||||
eventManager?: EventManagerInterface
|
||||
} = {}
|
||||
) {
|
||||
const scene = overrides.scene ?? new THREE.Scene()
|
||||
const renderer = createMockRenderer()
|
||||
const eventManager = overrides.eventManager ?? createMockEventManager()
|
||||
const camera = new THREE.PerspectiveCamera()
|
||||
const getActiveCamera = () => camera
|
||||
const setupCamera = vi.fn()
|
||||
const setupGizmo = vi.fn()
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
renderer,
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
setupCamera,
|
||||
setupGizmo
|
||||
)
|
||||
|
||||
return {
|
||||
manager,
|
||||
scene,
|
||||
renderer,
|
||||
eventManager,
|
||||
camera,
|
||||
setupCamera,
|
||||
setupGizmo
|
||||
}
|
||||
}
|
||||
|
||||
function createMeshModel(name = 'TestModel'): THREE.Group {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const group = new THREE.Group()
|
||||
group.name = name
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
describe('SceneModelManager', () => {
|
||||
describe('constructor', () => {
|
||||
it('initializes default state', () => {
|
||||
const { manager } = createManager()
|
||||
|
||||
expect(manager.currentModel).toBeNull()
|
||||
expect(manager.originalModel).toBeNull()
|
||||
expect(manager.originalRotation).toBeNull()
|
||||
expect(manager.currentUpDirection).toBe('original')
|
||||
expect(manager.materialMode).toBe('original')
|
||||
expect(manager.originalFileName).toBeNull()
|
||||
expect(manager.originalURL).toBeNull()
|
||||
expect(manager.appliedTexture).toBeNull()
|
||||
expect(manager.skeletonHelper).toBeNull()
|
||||
expect(manager.showSkeleton).toBe(false)
|
||||
})
|
||||
|
||||
it('creates material instances', () => {
|
||||
const { manager } = createManager()
|
||||
|
||||
expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial)
|
||||
expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial)
|
||||
expect(manager.wireframeMaterial.wireframe).toBe(true)
|
||||
expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial)
|
||||
expect(manager.standardMaterial).toBeInstanceOf(
|
||||
THREE.MeshStandardMaterial
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes all materials', () => {
|
||||
const { manager } = createManager()
|
||||
|
||||
const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose')
|
||||
const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose')
|
||||
const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose')
|
||||
const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose')
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(normalDispose).toHaveBeenCalled()
|
||||
expect(standardDispose).toHaveBeenCalled()
|
||||
expect(wireframeDispose).toHaveBeenCalled()
|
||||
expect(depthDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes applied texture', () => {
|
||||
const { manager } = createManager()
|
||||
const texture = new THREE.Texture()
|
||||
const textureDispose = vi.spyOn(texture, 'dispose')
|
||||
manager.appliedTexture = texture
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(textureDispose).toHaveBeenCalled()
|
||||
expect(manager.appliedTexture).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSTLMaterial', () => {
|
||||
it('returns a MeshStandardMaterial with expected properties', () => {
|
||||
const { manager } = createManager()
|
||||
const mat = manager.createSTLMaterial()
|
||||
|
||||
expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial)
|
||||
expect(mat.color.getHex()).toBe(0x808080)
|
||||
expect(mat.metalness).toBe(0.1)
|
||||
expect(mat.roughness).toBe(0.8)
|
||||
expect(mat.side).toBe(THREE.DoubleSide)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addModelToScene', () => {
|
||||
it('adds the model to the scene and sets currentModel', () => {
|
||||
const { manager, scene } = createManager()
|
||||
const model = createMeshModel()
|
||||
|
||||
manager.addModelToScene(model)
|
||||
|
||||
expect(manager.currentModel).toBe(model)
|
||||
expect(model.name).toBe('MainModel')
|
||||
expect(scene.children).toContain(model)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupModel', () => {
|
||||
it('scales and positions the model, then adds to scene', async () => {
|
||||
const { manager, scene, setupCamera } = createManager()
|
||||
const model = createMeshModel()
|
||||
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(manager.currentModel).toBe(model)
|
||||
expect(model.name).toBe('MainModel')
|
||||
expect(scene.children).toContain(model)
|
||||
expect(setupCamera).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not skip materialMode when it differs from original', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
|
||||
// setupModel checks materialMode !== 'original' and calls
|
||||
// setMaterialMode, but the guard `mode === this.materialMode`
|
||||
// causes it to no-op. Then setupModelMaterials resets to 'original'.
|
||||
manager.materialMode = 'wireframe'
|
||||
const spy = vi.spyOn(manager, 'setMaterialMode')
|
||||
await manager.setupModel(model)
|
||||
|
||||
// setMaterialMode is called with the stored mode and then 'original'
|
||||
expect(spy).toHaveBeenCalledWith('wireframe')
|
||||
expect(spy).toHaveBeenCalledWith('original')
|
||||
})
|
||||
|
||||
it('applies current up direction if not original', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
|
||||
manager.currentUpDirection = '+z'
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'upDirectionChange',
|
||||
'+z'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setOriginalModel', () => {
|
||||
it('stores the original model reference', () => {
|
||||
const { manager } = createManager()
|
||||
const model = new THREE.Group()
|
||||
|
||||
manager.setOriginalModel(model)
|
||||
|
||||
expect(manager.originalModel).toBe(model)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearModel', () => {
|
||||
it('removes non-environment objects from scene', async () => {
|
||||
const { manager, scene } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
const light = new THREE.DirectionalLight()
|
||||
scene.add(light)
|
||||
|
||||
manager.clearModel()
|
||||
|
||||
expect(manager.currentModel).toBeNull()
|
||||
expect(scene.children).toContain(light)
|
||||
})
|
||||
|
||||
it('disposes mesh geometry and materials', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
const geoDispose = vi.spyOn(mesh.geometry, 'dispose')
|
||||
const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose')
|
||||
|
||||
await manager.setupModel(model)
|
||||
manager.clearModel()
|
||||
|
||||
expect(geoDispose).toHaveBeenCalled()
|
||||
expect(matDispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('resets all state to defaults', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
manager.originalFileName = 'test.glb'
|
||||
manager.originalURL = 'http://example.com/test.glb'
|
||||
manager.originalModel = model
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(manager.currentModel).toBeNull()
|
||||
expect(manager.originalModel).toBeNull()
|
||||
expect(manager.originalRotation).toBeNull()
|
||||
expect(manager.currentUpDirection).toBe('original')
|
||||
expect(manager.originalFileName).toBeNull()
|
||||
expect(manager.originalURL).toBeNull()
|
||||
})
|
||||
|
||||
it('disposes applied texture', () => {
|
||||
const { manager } = createManager()
|
||||
const texture = new THREE.Texture()
|
||||
const textureDispose = vi.spyOn(texture, 'dispose')
|
||||
manager.appliedTexture = texture
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(textureDispose).toHaveBeenCalled()
|
||||
expect(manager.appliedTexture).toBeNull()
|
||||
})
|
||||
|
||||
it('removes and disposes skeleton helper', async () => {
|
||||
const { manager, scene } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
const mockHelper = new THREE.SkeletonHelper(model)
|
||||
const helperDispose = vi.spyOn(mockHelper, 'dispose')
|
||||
manager.skeletonHelper = mockHelper
|
||||
scene.add(mockHelper)
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(helperDispose).toHaveBeenCalled()
|
||||
expect(manager.skeletonHelper).toBeNull()
|
||||
expect(manager.showSkeleton).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMaterialMode', () => {
|
||||
it('does nothing when no current model', () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
|
||||
manager.setMaterialMode('normal')
|
||||
|
||||
expect(eventManager.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when mode is unchanged', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
vi.mocked(eventManager.emitEvent).mockClear()
|
||||
|
||||
manager.setMaterialMode('original')
|
||||
|
||||
expect(eventManager.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches to normal material', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setMaterialMode('normal')
|
||||
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
|
||||
expect(manager.materialMode).toBe('normal')
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'materialModeChange',
|
||||
'normal'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches to wireframe material', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setMaterialMode('wireframe')
|
||||
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
|
||||
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'materialModeChange',
|
||||
'wireframe'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches to depth material', async () => {
|
||||
const { manager, renderer } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setMaterialMode('depth')
|
||||
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
|
||||
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
})
|
||||
|
||||
it('restores original material when switching back', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
const originalMat = mesh.material
|
||||
|
||||
manager.setMaterialMode('normal')
|
||||
manager.setMaterialMode('original')
|
||||
|
||||
expect(mesh.material).toBe(originalMat)
|
||||
})
|
||||
|
||||
it('uses appliedTexture when no original material stored', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
const texture = new THREE.Texture()
|
||||
manager.appliedTexture = texture
|
||||
|
||||
manager.addModelToScene(model)
|
||||
manager.materialMode = 'normal'
|
||||
manager.setMaterialMode('original')
|
||||
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
|
||||
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
|
||||
})
|
||||
|
||||
it('sets renderer color space to SRGB for non-depth modes', async () => {
|
||||
const { manager, renderer } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setMaterialMode('depth')
|
||||
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
|
||||
manager.setMaterialMode('normal')
|
||||
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
|
||||
})
|
||||
|
||||
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.originalModel = new THREE.BufferGeometry()
|
||||
;(manager.originalModel as THREE.BufferGeometry).setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3)
|
||||
)
|
||||
|
||||
manager.setMaterialMode('wireframe')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'materialModeChange',
|
||||
'wireframe'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupModelMaterials', () => {
|
||||
it('stores original materials in the WeakMap', () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
const originalMat = mesh.material
|
||||
|
||||
manager.currentModel = model
|
||||
manager.setupModelMaterials(model)
|
||||
|
||||
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUpDirection', () => {
|
||||
it('does nothing when no current model', () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
|
||||
manager.setUpDirection('+x')
|
||||
|
||||
expect(eventManager.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores the original rotation on first call', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setUpDirection('+x')
|
||||
|
||||
expect(manager.originalRotation).not.toBeNull()
|
||||
})
|
||||
|
||||
it('applies correct rotation for each direction', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
const directions: Array<{
|
||||
dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
axis: 'x' | 'z'
|
||||
value: number
|
||||
}> = [
|
||||
{ dir: '-x', axis: 'z', value: Math.PI / 2 },
|
||||
{ dir: '+x', axis: 'z', value: -Math.PI / 2 },
|
||||
{ dir: '-y', axis: 'x', value: Math.PI },
|
||||
{ dir: '-z', axis: 'x', value: Math.PI / 2 },
|
||||
{ dir: '+z', axis: 'x', value: -Math.PI / 2 }
|
||||
]
|
||||
|
||||
for (const { dir, axis, value } of directions) {
|
||||
manager.setUpDirection(dir)
|
||||
expect(model.rotation[axis]).toBeCloseTo(value)
|
||||
expect(manager.currentUpDirection).toBe(dir)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'upDirectionChange',
|
||||
dir
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores original rotation before applying new direction', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setUpDirection('+x')
|
||||
const zAfterX = model.rotation.z
|
||||
|
||||
manager.setUpDirection('-z')
|
||||
expect(model.rotation.x).toBeCloseTo(Math.PI / 2)
|
||||
expect(model.rotation.z).not.toBeCloseTo(zAfterX)
|
||||
})
|
||||
|
||||
it('emits upDirectionChange event', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setUpDirection('original')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'upDirectionChange',
|
||||
'original'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSkeleton', () => {
|
||||
it('returns false when no current model', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.hasSkeleton()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for model without skeleton', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(manager.hasSkeleton()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for model with SkinnedMesh', () => {
|
||||
const { manager } = createManager()
|
||||
const group = new THREE.Group()
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
const material = new THREE.MeshStandardMaterial()
|
||||
const bones = [new THREE.Bone(), new THREE.Bone()]
|
||||
bones[0].add(bones[1])
|
||||
const skeleton = new THREE.Skeleton(bones)
|
||||
const skinnedMesh = new THREE.SkinnedMesh(geometry, material)
|
||||
skinnedMesh.add(bones[0])
|
||||
skinnedMesh.bind(skeleton)
|
||||
group.add(skinnedMesh)
|
||||
|
||||
manager.currentModel = group
|
||||
|
||||
expect(manager.hasSkeleton()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setShowSkeleton', () => {
|
||||
it('sets showSkeleton flag', () => {
|
||||
const { manager } = createManager()
|
||||
manager.setShowSkeleton(true)
|
||||
expect(manager.showSkeleton).toBe(true)
|
||||
})
|
||||
|
||||
it('emits skeletonVisibilityChange event', () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
|
||||
manager.setShowSkeleton(true)
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'skeletonVisibilityChange',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides existing skeleton helper when set to false', async () => {
|
||||
const { manager, scene } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
const helper = new THREE.SkeletonHelper(model)
|
||||
manager.skeletonHelper = helper
|
||||
scene.add(helper)
|
||||
|
||||
manager.setShowSkeleton(false)
|
||||
|
||||
expect(helper.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('shows existing skeleton helper when set to true', async () => {
|
||||
const { manager, scene } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
const helper = new THREE.SkeletonHelper(model)
|
||||
helper.visible = false
|
||||
manager.skeletonHelper = helper
|
||||
scene.add(helper)
|
||||
|
||||
manager.setShowSkeleton(true)
|
||||
|
||||
expect(helper.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('containsSplatMesh', () => {
|
||||
it('returns false when no model', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular model', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for explicit null argument', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLY mode switching', () => {
|
||||
function createPLYManager() {
|
||||
const ctx = createManager()
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
|
||||
)
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry.clone(),
|
||||
ctx.manager.standardMaterial.clone()
|
||||
)
|
||||
const group = new THREE.Group()
|
||||
group.name = 'MainModel'
|
||||
group.add(mesh)
|
||||
ctx.scene.add(group)
|
||||
|
||||
ctx.manager.currentModel = group
|
||||
ctx.manager.originalModel = geometry
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('recreates model as point cloud', () => {
|
||||
const { manager, scene, eventManager } = createPLYManager()
|
||||
|
||||
manager.setMaterialMode('pointCloud')
|
||||
|
||||
const mainModel = scene.children.find((c) => c.name === 'MainModel')
|
||||
expect(mainModel).toBeDefined()
|
||||
const points = mainModel!.children.find((c) => c instanceof THREE.Points)
|
||||
expect(points).toBeInstanceOf(THREE.Points)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'materialModeChange',
|
||||
'pointCloud'
|
||||
)
|
||||
})
|
||||
|
||||
it('recreates model as wireframe mesh', () => {
|
||||
const { manager, scene } = createPLYManager()
|
||||
|
||||
manager.setMaterialMode('wireframe')
|
||||
|
||||
const mainModel = scene.children.find((c) => c.name === 'MainModel')
|
||||
expect(mainModel).toBeDefined()
|
||||
|
||||
let foundWireframe = false
|
||||
mainModel!.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
child.material instanceof THREE.MeshBasicMaterial
|
||||
) {
|
||||
foundWireframe = child.material.wireframe
|
||||
}
|
||||
})
|
||||
expect(foundWireframe).toBe(true)
|
||||
})
|
||||
|
||||
it('uses vertex colors when available', () => {
|
||||
const { manager, scene } = createManager()
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
|
||||
)
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
|
||||
)
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry.clone(),
|
||||
new THREE.MeshBasicMaterial()
|
||||
)
|
||||
const group = new THREE.Group()
|
||||
group.name = 'MainModel'
|
||||
group.add(mesh)
|
||||
scene.add(group)
|
||||
|
||||
manager.currentModel = group
|
||||
manager.originalModel = geometry
|
||||
|
||||
manager.setMaterialMode('pointCloud')
|
||||
|
||||
const mainModel = scene.children.find((c) => c.name === 'MainModel')
|
||||
const points = mainModel!.children.find(
|
||||
(c) => c instanceof THREE.Points
|
||||
) as THREE.Points
|
||||
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
|
||||
})
|
||||
|
||||
it('removes old MainModel objects before adding new one', () => {
|
||||
const { manager, scene } = createPLYManager()
|
||||
|
||||
manager.setMaterialMode('wireframe')
|
||||
|
||||
const mainModels = scene.children.filter((c) => c.name === 'MainModel')
|
||||
expect(mainModels).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -16,7 +17,7 @@ function drawSelectionBorder(
|
||||
if (selectedItems.size <= 1) return
|
||||
|
||||
// Use the same bounds calculation as the toolbox
|
||||
const bounds = createBounds(selectedItems, 10)
|
||||
const bounds = createBounds(selectedItems, SELECTION_BOUNDS_PADDING)
|
||||
if (!bounds) return
|
||||
|
||||
const [x, y, width, height] = bounds
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Point,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
@@ -653,4 +654,47 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measure() collapsed branching', () => {
|
||||
let out: Rect
|
||||
|
||||
beforeEach(() => {
|
||||
out = [0, 0, 0, 0] as unknown as Rect
|
||||
node.flags.collapsed = true
|
||||
node.size[0] = 150
|
||||
node.size[1] = 10
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
test('legacy mode uses NODE_TITLE_HEIGHT-based fallback when no ctx', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
node.measure(out)
|
||||
|
||||
// No ctx → legacy collapsed branch falls back to NODE_COLLAPSED_WIDTH
|
||||
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('Vue mode uses this.size directly for collapsed nodes', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
node.measure(out)
|
||||
|
||||
// Vue mode collapsed takes the expanded-style branch
|
||||
expect(out[2]).toBe(150)
|
||||
expect(out[3]).toBe(10 + LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('Vue mode expanded behaves identically to legacy expanded', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
node.flags.collapsed = false
|
||||
node.size[0] = 200
|
||||
node.size[1] = 120
|
||||
node.measure(out)
|
||||
|
||||
expect(out[2]).toBe(200)
|
||||
expect(out[3]).toBe(120 + LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2088,7 +2088,10 @@ export class LGraphNode
|
||||
|
||||
out[0] = this.pos[0]
|
||||
out[1] = this.pos[1] + -titleHeight
|
||||
if (!this.flags?.collapsed) {
|
||||
// In Vue mode, `this.size` is kept in sync with the DOM-measured
|
||||
// collapsed dimensions via ResizeObserver → layoutStore → useLayoutSync,
|
||||
// so the expanded branch produces correct bounds for collapsed nodes too.
|
||||
if (!this.flags?.collapsed || LiteGraph.vueNodesMode) {
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"sortDefault": "الافتراضي",
|
||||
"sortPopular": "الأكثر شعبية",
|
||||
"sortRecent": "الأحدث",
|
||||
"sortUnsorted": "غير مرتب",
|
||||
"sortZA": "ي-أ",
|
||||
"sortingType": "نوع الفرز",
|
||||
"tags": "الوسوم",
|
||||
@@ -584,6 +585,8 @@
|
||||
"publishButton": "النشر على ComfyHub",
|
||||
"publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.",
|
||||
"publishFailedTitle": "فشل النشر",
|
||||
"publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.",
|
||||
"publishSuccessTitle": "تم النشر بنجاح",
|
||||
"removeExampleImage": "إزالة الصورة النموذجية",
|
||||
"selectAThumbnail": "اختر صورة مصغرة",
|
||||
"shareAs": "مشاركة كـ",
|
||||
@@ -1214,7 +1217,9 @@
|
||||
"nothingToDelete": "لا يوجد ما يمكن حذفه",
|
||||
"nothingToDuplicate": "لا يوجد ما يمكن نسخه",
|
||||
"nothingToRename": "لا يوجد ما يمكن إعادة تسميته",
|
||||
"off": "إيقاف",
|
||||
"ok": "موافق",
|
||||
"on": "تشغيل",
|
||||
"openManager": "فتح المدير",
|
||||
"openNewIssue": "فتح مشكلة جديدة",
|
||||
"or": "أو",
|
||||
@@ -1641,7 +1646,16 @@
|
||||
"exportModel": "تصدير النموذج",
|
||||
"exportRecording": "تصدير التسجيل",
|
||||
"exportingModel": "جارٍ تصدير النموذج...",
|
||||
"fitToViewer": "تكييف مع العارض",
|
||||
"fov": "مجال الرؤية (FOV)",
|
||||
"gizmo": {
|
||||
"label": "أداة التحكم",
|
||||
"reset": "إعادة ضبط التحويل",
|
||||
"rotate": "تدوير",
|
||||
"scale": "تغيير الحجم",
|
||||
"toggle": "تبديل أداة التحكم",
|
||||
"translate": "تحريك"
|
||||
},
|
||||
"hdri": {
|
||||
"changeFile": "تغيير HDRI",
|
||||
"intensity": "الشدة",
|
||||
@@ -2236,6 +2250,7 @@
|
||||
"Reve": "Reve",
|
||||
"Rodin": "رودان",
|
||||
"Runway": "رن واي",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "سورا",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
@@ -2309,6 +2324,7 @@
|
||||
"stable_cascade": "سلسلة ثابتة",
|
||||
"string": "سلسلة نصية",
|
||||
"style_model": "نموذج النمط",
|
||||
"supir": "supir",
|
||||
"text": "نص",
|
||||
"textgen": "textgen",
|
||||
"training": "تدريب",
|
||||
@@ -2495,6 +2511,8 @@
|
||||
"advancedInputs": "مدخلات متقدمة",
|
||||
"bypass": "تجاوز",
|
||||
"color": "لون العقدة",
|
||||
"editSubgraph": "تعديل الرسم البياني الفرعي",
|
||||
"editTitle": "تعديل العنوان",
|
||||
"enterSubgraph": "دخول الرسم الفرعي",
|
||||
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
@@ -3445,7 +3463,9 @@
|
||||
"failedToPurchaseCredits": "فشل في شراء الرصيد: {error}",
|
||||
"failedToQueue": "فشل في الإضافة إلى قائمة الانتظار",
|
||||
"failedToSaveDraft": "فشل في حفظ مسودة سير العمل",
|
||||
"failedToSetGizmoMode": "فشل في تعيين وضع أداة التحكم",
|
||||
"failedToToggleCamera": "فشل في تبديل الكاميرا",
|
||||
"failedToToggleGizmo": "فشل في تبديل أداة التحكم",
|
||||
"failedToToggleGrid": "فشل في تبديل الشبكة",
|
||||
"failedToUpdateBackgroundColor": "فشل في تحديث لون الخلفية",
|
||||
"failedToUpdateBackgroundImage": "فشل في تحديث صورة الخلفية",
|
||||
|
||||
@@ -472,6 +472,137 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
|
||||
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "الإطار الأول",
|
||||
"tooltip": "صورة الإطار الأول للفيديو."
|
||||
},
|
||||
"last_frame": {
|
||||
"name": "الإطار الأخير",
|
||||
"tooltip": "صورة الإطار الأخير للفيديو."
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "توليد الصوت"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "الموجه"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "النسبة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "علامة مائية",
|
||||
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2ReferenceNode": {
|
||||
"description": "إنشاء أو تعديل أو تمديد فيديو باستخدام Seedance 2.0 مع صور أو فيديوهات أو صوتيات مرجعية. يدعم المراجع متعددة الوسائط، وتحرير الفيديو، وتمديد الفيديو.",
|
||||
"display_name": "ByteDance Seedance 2.0 من مرجع إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "توليد الصوت"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "الموجه"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "النسبة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "علامة مائية",
|
||||
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2TextToVideoNode": {
|
||||
"description": "إنشاء فيديو باستخدام نماذج Seedance 2.0 بناءً على موجه نصي.",
|
||||
"display_name": "ByteDance Seedance 2.0 من نص إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "توليد الصوت"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "الموجه"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "النسبة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "علامة مائية",
|
||||
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
|
||||
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
|
||||
@@ -1362,6 +1493,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ColorTransfer": {
|
||||
"description": "مطابقة ألوان صورة مع أخرى باستخدام خوارزميات متنوعة.",
|
||||
"display_name": "ColorTransfer",
|
||||
"inputs": {
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "الصورة أو الصور المرجعية لمطابقة الألوان معها. إذا لم يتم توفيرها، سيتم تخطي المعالجة."
|
||||
},
|
||||
"image_target": {
|
||||
"name": "image_target",
|
||||
"tooltip": "الصورة أو الصور التي سيتم تطبيق تحويل الألوان عليها."
|
||||
},
|
||||
"method": {
|
||||
"name": "method"
|
||||
},
|
||||
"source_stats": {
|
||||
"name": "source_stats",
|
||||
"tooltip": "per_frame: كل إطار يُطابق مع image_ref بشكل فردي. uniform: تجميع إحصائيات جميع الإطارات المصدرية كأساس، والمطابقة مع image_ref. target_frame: استخدام إطار محدد كأساس للتحويل إلى image_ref، ويُطبق بشكل موحد على جميع الإطارات (يحافظ على الفروقات النسبية)."
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CombineHooks2": {
|
||||
"display_name": "دمج الخطافات [2]",
|
||||
"inputs": {
|
||||
@@ -5461,6 +5622,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JsonExtractString": {
|
||||
"display_name": "استخراج سلسلة من JSON",
|
||||
"inputs": {
|
||||
"json_string": {
|
||||
"name": "سلسلة_json"
|
||||
},
|
||||
"key": {
|
||||
"name": "مفتاح"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KSampler": {
|
||||
"description": "يستخدم النموذج المقدم، والتوجيه الإيجابي والسلبي لإزالة الضجيج من الصورة الكامنة.",
|
||||
"display_name": "KSampler",
|
||||
@@ -13755,6 +13932,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SUPIRApply": {
|
||||
"display_name": "SUPIRApply",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"model_patch": {
|
||||
"name": "model_patch"
|
||||
},
|
||||
"restore_cfg": {
|
||||
"name": "restore_cfg",
|
||||
"tooltip": "يسحب المخرجات المنزوعة الضوضاء نحو الإدخال الكامن. قيمة أعلى = تطابق أقوى مع الإدخال. ۰ لتعطيل الميزة."
|
||||
},
|
||||
"restore_cfg_s_tmin": {
|
||||
"name": "restore_cfg_s_tmin",
|
||||
"tooltip": "عتبة سيغما التي دونها يتم تعطيل restore_cfg."
|
||||
},
|
||||
"strength_end": {
|
||||
"name": "strength_end",
|
||||
"tooltip": "التحكم في قوة التأثير في نهاية العينة (سيغما منخفضة). يتم الاستيفاء خطياً من البداية."
|
||||
},
|
||||
"strength_start": {
|
||||
"name": "strength_start",
|
||||
"tooltip": "التحكم في قوة التأثير في بداية العينة (سيغما عالية)."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SV3D_Conditioning": {
|
||||
"display_name": "تهيئة SV3D",
|
||||
"inputs": {
|
||||
@@ -14761,6 +14976,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloTextToMusic": {
|
||||
"description": "إنشاء موسيقى من وصف نصي باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. اترك المدة ۰ ليقوم النموذج بتحديدها تلقائياً من الوصف.",
|
||||
"display_name": "تحويل النص إلى موسيقى بواسطة Sonilo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة",
|
||||
"tooltip": "المدة المستهدفة بالثواني. ضع القيمة ۰ ليقوم النموذج بتحديد المدة تلقائياً من الوصف. الحد الأقصى: ٦ دقائق."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف",
|
||||
"tooltip": "وصف نصي يصف الموسيقى المطلوب إنشاؤها."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloVideoToMusic": {
|
||||
"description": "إنشاء موسيقى من محتوى الفيديو باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. يقوم بتحليل الفيديو وإنشاء موسيقى متوافقة.",
|
||||
"display_name": "تحويل الفيديو إلى موسيقى بواسطة Sonilo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف",
|
||||
"tooltip": "وصف نصي اختياري لتوجيه إنشاء الموسيقى. اتركه فارغاً للحصول على أفضل جودة - سيقوم النموذج بتحليل محتوى الفيديو بالكامل."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
|
||||
},
|
||||
"video": {
|
||||
"name": "الفيديو",
|
||||
"tooltip": "فيديو الإدخال لإنشاء الموسيقى منه. الحد الأقصى للمدة: ٦ دقائق."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"description": "يفصل الصوت إلى القناتين اليسرى واليمنى.",
|
||||
"display_name": "فصل قنوات الصوت",
|
||||
@@ -16025,6 +16292,10 @@
|
||||
"thinking": {
|
||||
"name": "التفكير",
|
||||
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "استخدام القالب الافتراضي",
|
||||
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16076,6 +16347,10 @@
|
||||
"thinking": {
|
||||
"name": "التفكير",
|
||||
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "استخدام القالب الافتراضي",
|
||||
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -1670,6 +1670,7 @@
|
||||
"attention_experiments": "attention_experiments",
|
||||
"flux": "flux",
|
||||
"kandinsky5": "kandinsky5",
|
||||
"postprocessing": "postprocessing",
|
||||
"hooks": "hooks",
|
||||
"combine": "combine",
|
||||
"math": "math",
|
||||
@@ -1701,7 +1702,6 @@
|
||||
"HitPaw": "HitPaw",
|
||||
"sd": "sd",
|
||||
"Ideogram": "Ideogram",
|
||||
"postprocessing": "postprocessing",
|
||||
"transform": "transform",
|
||||
"batch": "batch",
|
||||
"upscaling": "upscaling",
|
||||
@@ -1736,10 +1736,12 @@
|
||||
"save": "save",
|
||||
"upscale_diffusion": "upscale_diffusion",
|
||||
"clip": "clip",
|
||||
"Sonilo": "Sonilo",
|
||||
"Stability AI": "Stability AI",
|
||||
"stable_cascade": "stable_cascade",
|
||||
"3d_models": "3d_models",
|
||||
"style_model": "style_model",
|
||||
"supir": "supir",
|
||||
"Tencent": "Tencent",
|
||||
"textgen": "textgen",
|
||||
"Topaz": "Topaz",
|
||||
@@ -2992,6 +2994,7 @@
|
||||
"sortingType": "Sorting Type",
|
||||
"sortPopular": "Popular",
|
||||
"sortRecent": "Recent",
|
||||
"sortUnsorted": "Unsorted",
|
||||
"sortZA": "Z-A",
|
||||
"tags": "Tags",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
|
||||
@@ -472,6 +472,137 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
|
||||
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "first_frame",
|
||||
"tooltip": "First frame image for the video."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "watermark",
|
||||
"tooltip": "Whether to add a watermark to the video."
|
||||
},
|
||||
"last_frame": {
|
||||
"name": "last_frame",
|
||||
"tooltip": "Last frame image for the video."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generate_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "ratio"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2ReferenceNode": {
|
||||
"display_name": "ByteDance Seedance 2.0 Reference to Video",
|
||||
"description": "Generate, edit, or extend video using Seedance 2.0 with reference images, videos, and audio. Supports multimodal reference, video editing, and video extension.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "watermark",
|
||||
"tooltip": "Whether to add a watermark to the video."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generate_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "ratio"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2TextToVideoNode": {
|
||||
"display_name": "ByteDance Seedance 2.0 Text to Video",
|
||||
"description": "Generate video using Seedance 2.0 models based on a text prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "watermark",
|
||||
"tooltip": "Whether to add a watermark to the video."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generate_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "ratio"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"display_name": "ByteDance First-Last-Frame to Video",
|
||||
"description": "Generate video using prompt and first and last frames.",
|
||||
@@ -1362,6 +1493,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ColorTransfer": {
|
||||
"display_name": "ColorTransfer",
|
||||
"description": "Match the colors of one image to another using various algorithms.",
|
||||
"inputs": {
|
||||
"image_target": {
|
||||
"name": "image_target",
|
||||
"tooltip": "Image(s) to apply the color transform to."
|
||||
},
|
||||
"method": {
|
||||
"name": "method"
|
||||
},
|
||||
"source_stats": {
|
||||
"name": "source_stats",
|
||||
"tooltip": "per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)"
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
},
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "Reference image(s) to match colors to. If not provided, processing is skipped"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CombineHooks2": {
|
||||
"display_name": "Combine Hooks [2]",
|
||||
"inputs": {
|
||||
@@ -5461,6 +5622,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JsonExtractString": {
|
||||
"display_name": "Extract String from JSON",
|
||||
"inputs": {
|
||||
"json_string": {
|
||||
"name": "json_string"
|
||||
},
|
||||
"key": {
|
||||
"name": "key"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Kandinsky5ImageToVideo": {
|
||||
"display_name": "Kandinsky5ImageToVideo",
|
||||
"inputs": {
|
||||
@@ -14678,6 +14855,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloTextToMusic": {
|
||||
"display_name": "Sonilo Text to Music",
|
||||
"description": "Generate music from a text prompt using Sonilo's AI model. Leave duration at 0 to let the model infer it from the prompt.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt describing the music to generate."
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"tooltip": "Target duration in seconds. Set to 0 to let the model infer the duration from the prompt. Maximum: 6 minutes."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloVideoToMusic": {
|
||||
"display_name": "Sonilo Video to Music",
|
||||
"description": "Generate music from video content using Sonilo's AI model. Analyzes the video and creates matching music.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Input video to generate music from. Maximum duration: 6 minutes."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Optional text prompt to guide music generation. Leave empty for best quality - the model will fully analyze the video content."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"display_name": "Split Audio Channels",
|
||||
"description": "Separates the audio into left and right channels.",
|
||||
@@ -15421,6 +15650,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SUPIRApply": {
|
||||
"display_name": "SUPIRApply",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"model_patch": {
|
||||
"name": "model_patch"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"strength_start": {
|
||||
"name": "strength_start",
|
||||
"tooltip": "Control strength at the start of sampling (high sigma)."
|
||||
},
|
||||
"strength_end": {
|
||||
"name": "strength_end",
|
||||
"tooltip": "Control strength at the end of sampling (low sigma). Linearly interpolated from start."
|
||||
},
|
||||
"restore_cfg": {
|
||||
"name": "restore_cfg",
|
||||
"tooltip": "Pulls denoised output toward the input latent. Higher = stronger fidelity to input. 0 to disable."
|
||||
},
|
||||
"restore_cfg_s_tmin": {
|
||||
"name": "restore_cfg_s_tmin",
|
||||
"tooltip": "Sigma threshold below which restore_cfg is disabled."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SV3D_Conditioning": {
|
||||
"display_name": "SV3D_Conditioning",
|
||||
"inputs": {
|
||||
@@ -16005,6 +16272,10 @@
|
||||
"name": "thinking",
|
||||
"tooltip": "Operate in thinking mode if the model supports it."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "Use the built in system prompt/template if the model has one."
|
||||
},
|
||||
"sampling_mode_min_p": {
|
||||
"name": "min_p"
|
||||
},
|
||||
@@ -16056,6 +16327,10 @@
|
||||
"name": "thinking",
|
||||
"tooltip": "Operate in thinking mode if the model supports it."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "Use the built in system prompt/template if the model has one."
|
||||
},
|
||||
"sampling_mode_min_p": {
|
||||
"name": "min_p"
|
||||
},
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"sortDefault": "Predeterminado",
|
||||
"sortPopular": "Popular",
|
||||
"sortRecent": "Reciente",
|
||||
"sortUnsorted": "Sin ordenar",
|
||||
"sortZA": "Z-A",
|
||||
"sortingType": "Tipo de ordenación",
|
||||
"tags": "Etiquetas",
|
||||
@@ -584,6 +585,8 @@
|
||||
"publishButton": "Publicar en ComfyHub",
|
||||
"publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.",
|
||||
"publishFailedTitle": "Error al publicar",
|
||||
"publishSuccessDescription": "Tu flujo de trabajo ya está disponible en ComfyHub.",
|
||||
"publishSuccessTitle": "Publicado con éxito",
|
||||
"removeExampleImage": "Eliminar imagen de ejemplo",
|
||||
"selectAThumbnail": "Selecciona una miniatura",
|
||||
"shareAs": "Compartir como",
|
||||
@@ -1214,7 +1217,9 @@
|
||||
"nothingToDelete": "Nada para eliminar",
|
||||
"nothingToDuplicate": "Nada para duplicar",
|
||||
"nothingToRename": "Nada para renombrar",
|
||||
"off": "Apagado",
|
||||
"ok": "OK",
|
||||
"on": "Encendido",
|
||||
"openManager": "Abrir administrador",
|
||||
"openNewIssue": "Abrir nuevo problema",
|
||||
"or": "o",
|
||||
@@ -1641,7 +1646,16 @@
|
||||
"exportModel": "Exportar modelo",
|
||||
"exportRecording": "Exportar grabación",
|
||||
"exportingModel": "Exportando modelo...",
|
||||
"fitToViewer": "Ajustar al visor",
|
||||
"fov": "FOV",
|
||||
"gizmo": {
|
||||
"label": "Gizmo",
|
||||
"reset": "Restablecer transformación",
|
||||
"rotate": "Rotar",
|
||||
"scale": "Escalar",
|
||||
"toggle": "Gizmo",
|
||||
"translate": "Trasladar"
|
||||
},
|
||||
"hdri": {
|
||||
"changeFile": "Cambiar HDRI",
|
||||
"intensity": "Intensidad",
|
||||
@@ -2236,6 +2250,7 @@
|
||||
"Reve": "Reve",
|
||||
"Rodin": "Rodin",
|
||||
"Runway": "Runway",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "Sora",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
@@ -2309,6 +2324,7 @@
|
||||
"stable_cascade": "stable_cascade",
|
||||
"string": "cadena",
|
||||
"style_model": "modelo_de_estilo",
|
||||
"supir": "supir",
|
||||
"text": "texto",
|
||||
"textgen": "textgen",
|
||||
"training": "entrenamiento",
|
||||
@@ -2495,6 +2511,8 @@
|
||||
"advancedInputs": "ENTRADAS AVANZADAS",
|
||||
"bypass": "Omitir",
|
||||
"color": "Color del nodo",
|
||||
"editSubgraph": "Editar subgrafo",
|
||||
"editTitle": "Editar título",
|
||||
"enterSubgraph": "Entrar en subgrafo",
|
||||
"errorHelp": "Para más ayuda, {github} o {support}",
|
||||
"errorHelpGithub": "envía un issue en GitHub",
|
||||
@@ -3445,7 +3463,9 @@
|
||||
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
|
||||
"failedToQueue": "Error al encolar",
|
||||
"failedToSaveDraft": "No se pudo guardar el borrador del flujo de trabajo",
|
||||
"failedToSetGizmoMode": "No se pudo establecer el modo de gizmo",
|
||||
"failedToToggleCamera": "No se pudo alternar la cámara",
|
||||
"failedToToggleGizmo": "No se pudo alternar el gizmo",
|
||||
"failedToToggleGrid": "No se pudo alternar la cuadrícula",
|
||||
"failedToUpdateBackgroundColor": "No se pudo actualizar el color de fondo",
|
||||
"failedToUpdateBackgroundImage": "No se pudo actualizar la imagen de fondo",
|
||||
|
||||
@@ -472,6 +472,137 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
|
||||
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control después de generar"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "primer_fotograma",
|
||||
"tooltip": "Imagen del primer fotograma para el video."
|
||||
},
|
||||
"last_frame": {
|
||||
"name": "último_fotograma",
|
||||
"tooltip": "Imagen del último fotograma para el video."
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duración"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generar_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "relación"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolución"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "marca_de_agua",
|
||||
"tooltip": "Indica si se debe añadir una marca de agua al video."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2ReferenceNode": {
|
||||
"description": "Genera, edita o extiende video usando Seedance 2.0 con imágenes, videos y audio de referencia. Soporta referencia multimodal, edición de video y extensión de video.",
|
||||
"display_name": "ByteDance Seedance 2.0 Referencia a Video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duración"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generar_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "relación"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolución"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "marca_de_agua",
|
||||
"tooltip": "Indica si se debe añadir una marca de agua al video."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2TextToVideoNode": {
|
||||
"description": "Genera video usando modelos Seedance 2.0 a partir de un prompt de texto.",
|
||||
"display_name": "ByteDance Seedance 2.0 Texto a Video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duración"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "generar_audio"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "relación"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolución"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "marca_de_agua",
|
||||
"tooltip": "Indica si se debe añadir una marca de agua al video."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"description": "Generar video usando prompt y primer y último fotograma.",
|
||||
"display_name": "ByteDance Primer-Último-Fotograma a Video",
|
||||
@@ -1362,6 +1493,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ColorTransfer": {
|
||||
"description": "Iguala los colores de una imagen con otra utilizando varios algoritmos.",
|
||||
"display_name": "ColorTransfer",
|
||||
"inputs": {
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "Imagen(es) de referencia para igualar los colores. Si no se proporciona, se omite el procesamiento."
|
||||
},
|
||||
"image_target": {
|
||||
"name": "image_target",
|
||||
"tooltip": "Imagen(es) a las que se aplicará la transferencia de color."
|
||||
},
|
||||
"method": {
|
||||
"name": "method"
|
||||
},
|
||||
"source_stats": {
|
||||
"name": "source_stats",
|
||||
"tooltip": "per_frame: cada fotograma se iguala individualmente a image_ref. uniform: agrupa estadísticas de todos los fotogramas fuente como referencia, iguala a image_ref. target_frame: usa un fotograma elegido como referencia para la transformación a image_ref, aplicado uniformemente a todos los fotogramas (preserva las diferencias relativas)"
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CombineHooks2": {
|
||||
"display_name": "Combinar Hooks [2]",
|
||||
"inputs": {
|
||||
@@ -5461,6 +5622,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JsonExtractString": {
|
||||
"display_name": "Extraer cadena de JSON",
|
||||
"inputs": {
|
||||
"json_string": {
|
||||
"name": "json_string"
|
||||
},
|
||||
"key": {
|
||||
"name": "key"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KSampler": {
|
||||
"description": "Utiliza el modelo proporcionado, el acondicionamiento positivo y negativo para deshacer el ruido de la imagen latente.",
|
||||
"display_name": "KSampler",
|
||||
@@ -13755,6 +13932,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SUPIRApply": {
|
||||
"display_name": "SUPIRApply",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"model_patch": {
|
||||
"name": "model_patch"
|
||||
},
|
||||
"restore_cfg": {
|
||||
"name": "restore_cfg",
|
||||
"tooltip": "Atrae la salida denoised hacia el latent de entrada. Un valor más alto = mayor fidelidad a la entrada. 0 para desactivar."
|
||||
},
|
||||
"restore_cfg_s_tmin": {
|
||||
"name": "restore_cfg_s_tmin",
|
||||
"tooltip": "Umbral de sigma por debajo del cual restore_cfg se desactiva."
|
||||
},
|
||||
"strength_end": {
|
||||
"name": "strength_end",
|
||||
"tooltip": "Controla la intensidad al final del muestreo (sigma baja). Interpolado linealmente desde el inicio."
|
||||
},
|
||||
"strength_start": {
|
||||
"name": "strength_start",
|
||||
"tooltip": "Controla la intensidad al inicio del muestreo (sigma alta)."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SV3D_Conditioning": {
|
||||
"display_name": "SV3D_Acondicionamiento",
|
||||
"inputs": {
|
||||
@@ -14761,6 +14976,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloTextToMusic": {
|
||||
"description": "Genera música a partir de un texto usando el modelo de IA de Sonilo. Deja la duración en 0 para que el modelo la infiera del texto.",
|
||||
"display_name": "Sonilo Texto a Música",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"tooltip": "Duración objetivo en segundos. Pon 0 para que el modelo infiera la duración del texto. Máximo: 6 minutos."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Texto descriptivo de la música a generar."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloVideoToMusic": {
|
||||
"description": "Genera música a partir de contenido de video usando el modelo de IA de Sonilo. Analiza el video y crea música acorde.",
|
||||
"display_name": "Sonilo Video a Música",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Texto opcional para guiar la generación musical. Déjalo vacío para mejor calidad: el modelo analizará completamente el contenido del video."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Video de entrada del que generar música. Duración máxima: 6 minutos."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"description": "Separa el audio en canales izquierdo y derecho.",
|
||||
"display_name": "Separar canales de audio",
|
||||
@@ -16025,6 +16292,10 @@
|
||||
"thinking": {
|
||||
"name": "pensando",
|
||||
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16076,6 +16347,10 @@
|
||||
"thinking": {
|
||||
"name": "pensando",
|
||||
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"sortDefault": "پیشفرض",
|
||||
"sortPopular": "محبوب",
|
||||
"sortRecent": "جدیدترین",
|
||||
"sortUnsorted": "بدون مرتبسازی",
|
||||
"sortZA": "ی-الف",
|
||||
"sortingType": "نوع مرتبسازی",
|
||||
"tags": "برچسبها",
|
||||
@@ -584,6 +585,8 @@
|
||||
"publishButton": "انتشار در ComfyHub",
|
||||
"publishFailedDescription": "در هنگام انتشار گردشکار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
"publishFailedTitle": "انتشار ناموفق بود",
|
||||
"publishSuccessDescription": "گردشکار شما اکنون در ComfyHub فعال است.",
|
||||
"publishSuccessTitle": "با موفقیت منتشر شد",
|
||||
"removeExampleImage": "حذف تصویر نمونه",
|
||||
"selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید",
|
||||
"shareAs": "اشتراکگذاری به عنوان",
|
||||
@@ -1214,7 +1217,9 @@
|
||||
"nothingToDelete": "موردی برای حذف وجود ندارد",
|
||||
"nothingToDuplicate": "موردی برای تکرار وجود ندارد",
|
||||
"nothingToRename": "موردی برای تغییر نام وجود ندارد",
|
||||
"off": "خاموش",
|
||||
"ok": "تأیید",
|
||||
"on": "روشن",
|
||||
"openManager": "باز کردن مدیریت",
|
||||
"openNewIssue": "ایجاد گزارش جدید",
|
||||
"or": "یا",
|
||||
@@ -1641,7 +1646,16 @@
|
||||
"exportModel": "خروجی گرفتن مدل",
|
||||
"exportRecording": "خروجی گرفتن ضبط",
|
||||
"exportingModel": "در حال خروجی گرفتن مدل...",
|
||||
"fitToViewer": "تنظیم بر اساس نمایشگر",
|
||||
"fov": "زاویه دید (FOV)",
|
||||
"gizmo": {
|
||||
"label": "Gizmo",
|
||||
"reset": "بازنشانی تغییرات",
|
||||
"rotate": "چرخش",
|
||||
"scale": "مقیاس",
|
||||
"toggle": "فعال/غیرفعال کردن Gizmo",
|
||||
"translate": "جابجایی"
|
||||
},
|
||||
"hdri": {
|
||||
"changeFile": "تغییر HDRI",
|
||||
"intensity": "شدت",
|
||||
@@ -2236,6 +2250,7 @@
|
||||
"Reve": "Reve",
|
||||
"Rodin": "Rodin",
|
||||
"Runway": "Runway",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "Sora",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
@@ -2309,6 +2324,7 @@
|
||||
"stable_cascade": "stable cascade",
|
||||
"string": "رشته",
|
||||
"style_model": "مدل سبک",
|
||||
"supir": "supir",
|
||||
"text": "متن",
|
||||
"textgen": "textgen",
|
||||
"training": "آموزش",
|
||||
@@ -2495,6 +2511,8 @@
|
||||
"advancedInputs": "ورودیهای پیشرفته",
|
||||
"bypass": "عبور",
|
||||
"color": "رنگ نود",
|
||||
"editSubgraph": "ویرایش زیرگراف",
|
||||
"editTitle": "ویرایش عنوان",
|
||||
"enterSubgraph": "ورود به زیرگراف",
|
||||
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
|
||||
"errorHelpGithub": "ثبت یک issue در GitHub",
|
||||
@@ -3457,7 +3475,9 @@
|
||||
"failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}",
|
||||
"failedToQueue": "صفبندی انجام نشد",
|
||||
"failedToSaveDraft": "ذخیره پیشنویس workflow ناموفق بود",
|
||||
"failedToSetGizmoMode": "تنظیم حالت Gizmo ناموفق بود",
|
||||
"failedToToggleCamera": "تغییر وضعیت دوربین انجام نشد",
|
||||
"failedToToggleGizmo": "فعال/غیرفعال کردن Gizmo ناموفق بود",
|
||||
"failedToToggleGrid": "تغییر وضعیت شبکه انجام نشد",
|
||||
"failedToUpdateBackgroundColor": "بهروزرسانی رنگ پسزمینه انجام نشد",
|
||||
"failedToUpdateBackgroundImage": "بهروزرسانی تصویر پسزمینه انجام نشد",
|
||||
|
||||
@@ -472,6 +472,137 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.",
|
||||
"display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "اولین فریم",
|
||||
"tooltip": "تصویر اولین فریم برای ویدیو."
|
||||
},
|
||||
"last_frame": {
|
||||
"name": "آخرین فریم",
|
||||
"tooltip": "تصویر آخرین فریم برای ویدیو."
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینهسازی سرعت."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "تولید صدا"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "پرامپت"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed تعیین میکند که node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "واترمارک",
|
||||
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2ReferenceNode": {
|
||||
"description": "تولید، ویرایش یا گسترش ویدیو با استفاده از Seedance 2.0 و تصاویر مرجع، ویدیوها و صدا. پشتیبانی از مرجع چندرسانهای، ویرایش ویدیو و گسترش ویدیو.",
|
||||
"display_name": "ByteDance Seedance 2.0 مرجع به ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینهسازی سرعت."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "تولید صدا"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "پرامپت"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed تعیین میکند که node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "واترمارک",
|
||||
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2TextToVideoNode": {
|
||||
"description": "تولید ویدیو با استفاده از مدلهای Seedance 2.0 بر اساس پرامپت متنی.",
|
||||
"display_name": "ByteDance Seedance 2.0 متن به ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینهسازی سرعت."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"model_generate_audio": {
|
||||
"name": "تولید صدا"
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "پرامپت"
|
||||
},
|
||||
"model_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed تعیین میکند که node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "واترمارک",
|
||||
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.",
|
||||
"display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance",
|
||||
@@ -1362,6 +1493,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ColorTransfer": {
|
||||
"description": "هماهنگسازی رنگهای یک تصویر با تصویر دیگر با استفاده از الگوریتمهای مختلف.",
|
||||
"display_name": "ColorTransfer",
|
||||
"inputs": {
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "تصویر(ها)ی مرجع برای هماهنگسازی رنگها. در صورت عدم ارائه، پردازش انجام نمیشود."
|
||||
},
|
||||
"image_target": {
|
||||
"name": "image_target",
|
||||
"tooltip": "تصویر(ها)یی که تبدیل رنگ باید بر روی آنها اعمال شود."
|
||||
},
|
||||
"method": {
|
||||
"name": "method"
|
||||
},
|
||||
"source_stats": {
|
||||
"name": "source_stats",
|
||||
"tooltip": "per_frame: هر فریم به صورت جداگانه با image_ref هماهنگ میشود. uniform: آمار تمام فریمهای منبع به عنوان مبنا جمعآوری شده و با image_ref هماهنگ میشود. target_frame: یک فریم انتخابی به عنوان مبنا برای تبدیل به image_ref استفاده میشود و به طور یکنواخت بر همه فریمها اعمال میگردد (تفاوتهای نسبی حفظ میشوند)."
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CombineHooks2": {
|
||||
"display_name": "ترکیب هوکها [۲]",
|
||||
"inputs": {
|
||||
@@ -5461,6 +5622,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JsonExtractString": {
|
||||
"display_name": "استخراج رشته از JSON",
|
||||
"inputs": {
|
||||
"json_string": {
|
||||
"name": "json_string"
|
||||
},
|
||||
"key": {
|
||||
"name": "key"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KSampler": {
|
||||
"description": "با استفاده از مدل ارائهشده و شرطهای مثبت و منفی، تصویر نهفته را از نویز پاکسازی میکند.",
|
||||
"display_name": "KSampler",
|
||||
@@ -13755,6 +13932,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SUPIRApply": {
|
||||
"display_name": "SUPIRApply",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"model_patch": {
|
||||
"name": "model_patch"
|
||||
},
|
||||
"restore_cfg": {
|
||||
"name": "restore_cfg",
|
||||
"tooltip": "خروجی نویززداییشده را به سمت ورودی latent میکشاند. مقدار بالاتر = وفاداری بیشتر به ورودی. ۰ برای غیرفعالسازی."
|
||||
},
|
||||
"restore_cfg_s_tmin": {
|
||||
"name": "restore_cfg_s_tmin",
|
||||
"tooltip": "آستانه سیگما که زیر آن restore_cfg غیرفعال میشود."
|
||||
},
|
||||
"strength_end": {
|
||||
"name": "strength_end",
|
||||
"tooltip": "کنترل شدت در انتهای نمونهگیری (سیگما پایین). به صورت خطی از مقدار ابتدایی میانیابی میشود."
|
||||
},
|
||||
"strength_start": {
|
||||
"name": "strength_start",
|
||||
"tooltip": "کنترل شدت در ابتدای نمونهگیری (سیگما بالا)."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SV3D_Conditioning": {
|
||||
"display_name": "شرطگذاری SV3D",
|
||||
"inputs": {
|
||||
@@ -14761,6 +14976,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloTextToMusic": {
|
||||
"description": "تولید موسیقی از یک پرامپت متنی با استفاده از مدل هوش مصنوعی Sonilo. مدت زمان را روی ۰ قرار دهید تا مدل آن را از پرامپت تشخیص دهد.",
|
||||
"display_name": "تبدیل متن به موسیقی با Sonilo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"tooltip": "مدت زمان هدف به ثانیه. برای تشخیص خودکار مدت زمان توسط مدل، مقدار را روی ۰ قرار دهید. حداکثر: ۶ دقیقه."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "پرامپت متنی برای توصیف موسیقی مورد نظر جهت تولید."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته میشود اما برای سازگاری گراف حفظ شده است."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SoniloVideoToMusic": {
|
||||
"description": "تولید موسیقی از محتوای ویدیویی با استفاده از مدل هوش مصنوعی Sonilo. ویدیو را تحلیل کرده و موسیقی متناسب ایجاد میکند.",
|
||||
"display_name": "تبدیل ویدیو به موسیقی با Sonilo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "پرامپت متنی اختیاری برای راهنمایی تولید موسیقی. برای بهترین کیفیت خالی بگذارید - مدل به طور کامل محتوای ویدیو را تحلیل میکند."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته میشود اما برای سازگاری گراف حفظ شده است."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "ویدیوی ورودی برای تولید موسیقی. حداکثر مدت زمان: ۶ دقیقه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"description": "صدا را به کانالهای چپ و راست جدا میکند.",
|
||||
"display_name": "تقسیم کانالهای صوتی",
|
||||
@@ -16025,6 +16292,10 @@
|
||||
"thinking": {
|
||||
"name": "تفکر",
|
||||
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی میکند."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16076,6 +16347,10 @@
|
||||
"thinking": {
|
||||
"name": "تفکر",
|
||||
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی میکند."
|
||||
},
|
||||
"use_default_template": {
|
||||
"name": "use_default_template",
|
||||
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"sortDefault": "Par défaut",
|
||||
"sortPopular": "Populaire",
|
||||
"sortRecent": "Récent",
|
||||
"sortUnsorted": "Non trié",
|
||||
"sortZA": "Z-A",
|
||||
"sortingType": "Type de tri",
|
||||
"tags": "Tags",
|
||||
@@ -584,6 +585,8 @@
|
||||
"publishButton": "Publier sur ComfyHub",
|
||||
"publishFailedDescription": "Une erreur s’est produite lors de la publication de votre workflow. Veuillez réessayer.",
|
||||
"publishFailedTitle": "Échec de la publication",
|
||||
"publishSuccessDescription": "Votre workflow est maintenant en ligne sur ComfyHub.",
|
||||
"publishSuccessTitle": "Publication réussie",
|
||||
"removeExampleImage": "Supprimer l’image d’exemple",
|
||||
"selectAThumbnail": "Sélectionner une miniature",
|
||||
"shareAs": "Partager en tant que",
|
||||
@@ -1214,7 +1217,9 @@
|
||||
"nothingToDelete": "Rien à supprimer",
|
||||
"nothingToDuplicate": "Rien à dupliquer",
|
||||
"nothingToRename": "Rien à renommer",
|
||||
"off": "Désactivé",
|
||||
"ok": "OK",
|
||||
"on": "Activé",
|
||||
"openManager": "Ouvrir le gestionnaire",
|
||||
"openNewIssue": "Ouvrir un nouveau problème",
|
||||
"or": "ou",
|
||||
@@ -1641,7 +1646,16 @@
|
||||
"exportModel": "Exportation du modèle",
|
||||
"exportRecording": "Exporter l'enregistrement",
|
||||
"exportingModel": "Exportation du modèle en cours...",
|
||||
"fitToViewer": "Ajuster à la visionneuse",
|
||||
"fov": "FOV",
|
||||
"gizmo": {
|
||||
"label": "Gizmo",
|
||||
"reset": "Réinitialiser la transformation",
|
||||
"rotate": "Pivoter",
|
||||
"scale": "Échelle",
|
||||
"toggle": "Gizmo",
|
||||
"translate": "Déplacer"
|
||||
},
|
||||
"hdri": {
|
||||
"changeFile": "Changer l'HDRI",
|
||||
"intensity": "Intensité",
|
||||
@@ -2236,6 +2250,7 @@
|
||||
"Reve": "Reve",
|
||||
"Rodin": "Rodin",
|
||||
"Runway": "Runway",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "Sora",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
@@ -2309,6 +2324,7 @@
|
||||
"stable_cascade": "stable_cascade",
|
||||
"string": "chaîne",
|
||||
"style_model": "modèle_de_style",
|
||||
"supir": "supir",
|
||||
"text": "texte",
|
||||
"textgen": "textgen",
|
||||
"training": "entraînement",
|
||||
@@ -2495,6 +2511,8 @@
|
||||
"advancedInputs": "ENTRÉES AVANCÉES",
|
||||
"bypass": "Contourner",
|
||||
"color": "Couleur du nœud",
|
||||
"editSubgraph": "Modifier le sous-graphe",
|
||||
"editTitle": "Modifier le titre",
|
||||
"enterSubgraph": "Entrer dans le sous-graphe",
|
||||
"errorHelp": "Pour plus d'aide, {github} ou {support}",
|
||||
"errorHelpGithub": "soumettre un ticket GitHub",
|
||||
@@ -3445,7 +3463,9 @@
|
||||
"failedToPurchaseCredits": "Échec de l'achat de crédits : {error}",
|
||||
"failedToQueue": "Échec de la mise en file d'attente",
|
||||
"failedToSaveDraft": "Échec de l’enregistrement du brouillon du flux de travail",
|
||||
"failedToSetGizmoMode": "Échec du changement de mode du gizmo",
|
||||
"failedToToggleCamera": "Échec de l’activation/désactivation de la caméra",
|
||||
"failedToToggleGizmo": "Échec de l’activation du gizmo",
|
||||
"failedToToggleGrid": "Échec de l’activation/désactivation de la grille",
|
||||
"failedToUpdateBackgroundColor": "Échec de la mise à jour de la couleur d’arrière-plan",
|
||||
"failedToUpdateBackgroundImage": "Échec de la mise à jour de l’image d’arrière-plan",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user