mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-18 11:27:33 +00:00
Compare commits
9 Commits
refactor/e
...
feat/error
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db424661e7 | ||
|
|
d9b2e1204f | ||
|
|
a7d33edc4d | ||
|
|
bdc080823f | ||
|
|
8dbd3b206d | ||
|
|
efd32f0f13 | ||
|
|
3fc401a892 | ||
|
|
6a756d8de4 | ||
|
|
c1e54f4839 |
10
.github/workflows/release-draft-create.yaml
vendored
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,13 +53,7 @@ jobs:
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Desktop-specific release artifact with desktop distribution flags.
|
||||
DISTRIBUTION=desktop pnpm build
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -68,7 +62,6 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
@@ -86,7 +79,6 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: >-
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"block-no-empty": true,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"at-rule-disallowed-list": ["apply"],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"config",
|
||||
"theme",
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
|
||||
@@ -4,39 +4,3 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
border: none;
|
||||
background-color: var(--color-neutral-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background-color: var(--color-neutral-550);
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
background-color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
transition: opacity var(--default-transition-duration);
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.task-div .p-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,15 +101,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
|
||||
* required to style those generated nodes.
|
||||
*/
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,17 +187,13 @@ async function onLocaleChange(event: SelectChangeEvent) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: var(--default-transition-duration);
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-neutral-900),
|
||||
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
|
||||
}
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -269,43 +269,26 @@ const onFocus = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-700) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
|
||||
.p-accordionheader-toggle-icon {
|
||||
@@ -316,24 +299,11 @@ const onFocus = async () => {
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
border: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
background-color: transparent;
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
padding-left: calc(var(--spacing) * 5);
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'task-div group/task-card relative grid min-h-52 max-w-48',
|
||||
isLoading && 'opacity-75'
|
||||
)
|
||||
"
|
||||
class="task-div relative grid min-h-52 max-w-48"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
>
|
||||
<Card
|
||||
:class="
|
||||
cn(
|
||||
'relative h-full max-w-48 overflow-hidden',
|
||||
runner.state !== 'error' && 'opacity-65'
|
||||
)
|
||||
"
|
||||
:pt="cardPt"
|
||||
class="relative h-full max-w-48 overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -52,7 +43,7 @@
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
|
||||
class="task-card-ok pi pi-check"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,7 +55,6 @@ import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -93,9 +83,51 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
const cardPt = {
|
||||
header: { class: 'z-0' },
|
||||
body: { class: 'z-[1] grow justify-between' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ $t(`desktopDialogs.${id}.message`, message) }}
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="button.label"
|
||||
class="rounded-lg first:mr-auto"
|
||||
:label="
|
||||
$t(
|
||||
t(
|
||||
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
|
||||
button.label
|
||||
)
|
||||
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -40,3 +41,31 @@ const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.p-button-secondary {
|
||||
@apply text-white border-none bg-neutral-600;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
@apply bg-neutral-550;
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
@apply bg-neutral-500;
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
@apply bg-coral-red-600;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
@apply bg-coral-red-500;
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
@apply bg-coral-red-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
<h1 class="download-bg pi-download text-4xl font-bold">
|
||||
{{ $t('desktopUpdate.title') }}
|
||||
{{ t('desktopUpdate.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="m-8">
|
||||
<span>{{ $t('desktopUpdate.description') }}</span>
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<Button
|
||||
style="transform: translateX(-50%)"
|
||||
class="fixed bottom-0 left-1/2 my-8"
|
||||
:label="$t('maintenance.consoleLogs')"
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
<TerminalOutputDrawer
|
||||
v-model="terminalVisible"
|
||||
:header="$t('g.terminal')"
|
||||
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
|
||||
:header="t('g.terminal')"
|
||||
:default-message="t('desktopUpdate.terminalDefaultMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +44,7 @@ import Toast from 'primevue/toast'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
@@ -60,10 +61,10 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.download-bg::before {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
@apply m-0 absolute text-muted;
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: 2rem;
|
||||
|
||||
@@ -183,37 +183,33 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-steppanel) {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
@apply mt-8 flex justify-center bg-transparent;
|
||||
}
|
||||
|
||||
/* Remove default padding/margin from StepPanels to make scrollbar flush */
|
||||
:deep(.p-steppanels) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@apply p-0 m-0;
|
||||
}
|
||||
|
||||
/* Ensure StepPanel content container has no top/bottom padding */
|
||||
:deep(.p-steppanel-content) {
|
||||
padding: 0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
|
||||
:deep(.p-steppanels::-webkit-scrollbar) {
|
||||
width: calc(var(--spacing) * 4);
|
||||
@apply w-4;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-track) {
|
||||
background-color: transparent;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
|
||||
border: 4px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
||||
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -129,7 +129,6 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
@@ -221,14 +220,14 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
@apply m-0 absolute text-muted;
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseViewTemplate>
|
||||
<div class="sad-container grid items-center justify-evenly">
|
||||
<div class="sad-container">
|
||||
<!-- Right side image -->
|
||||
<img
|
||||
class="sad-girl"
|
||||
@@ -79,7 +79,10 @@ const continueToInstall = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.sad-container {
|
||||
@apply grid items-center justify-evenly;
|
||||
grid-template-columns: 25rem 1fr;
|
||||
|
||||
& > * {
|
||||
|
||||
@@ -232,6 +232,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
/* Hide the xterm scrollbar completely */
|
||||
:deep(.p-terminal) .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -44,12 +44,6 @@ export const TestIds = {
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
@@ -80,7 +74,6 @@ export type TestIdValue =
|
||||
| (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.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -104,13 +104,15 @@ test.describe('Missing models warning', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
@@ -121,13 +123,15 @@ test.describe('Missing models warning', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
@@ -168,10 +172,8 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
@@ -184,10 +186,8 @@ test.describe('Missing models warning', () => {
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -199,15 +199,13 @@ test.describe('Missing models warning', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = comfyPage.page.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadAllButton.click()
|
||||
await downloadButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
@@ -231,13 +229,12 @@ test.describe('Missing models warning', () => {
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await checkbox.click()
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
await changeSettingPromise
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 91 KiB |
@@ -1,8 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
@@ -12,17 +10,6 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
@@ -145,24 +132,28 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should be visible
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toBeVisible()
|
||||
|
||||
// Click color picker button
|
||||
await colorPickerButton.click()
|
||||
|
||||
// Color picker dropdown should be visible
|
||||
const colorPickerGroup = getColorPickerGroup(comfyPage)
|
||||
await expect(colorPickerGroup).toBeVisible()
|
||||
const colorPickerDropdown = comfyPage.page.locator(
|
||||
'.color-picker-container'
|
||||
)
|
||||
await expect(colorPickerDropdown).toBeVisible()
|
||||
|
||||
// Select a color (e.g., blue)
|
||||
const blueColorOption = colorPickerGroup.getByTestId(
|
||||
TestIds.selectionToolbox.colorBlue
|
||||
const blueColorOption = colorPickerDropdown.locator(
|
||||
'i[data-testid="blue"]'
|
||||
)
|
||||
await blueColorOption.click()
|
||||
|
||||
// Dropdown should close after selection
|
||||
await expect(colorPickerGroup).not.toBeVisible()
|
||||
await expect(colorPickerDropdown).not.toBeVisible()
|
||||
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
@@ -181,21 +172,22 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
|
||||
// Initially should show default color
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
|
||||
// Click color picker and select a color
|
||||
await colorPickerButton.click()
|
||||
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
|
||||
TestIds.selectionToolbox.colorRed
|
||||
const redColorOption = comfyPage.page.locator(
|
||||
'.color-picker-container i[data-testid="red"]'
|
||||
)
|
||||
await redColorOption.click()
|
||||
|
||||
// Button should now show the selected color
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
|
||||
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
|
||||
})
|
||||
|
||||
test('color picker shows mixed state for differently colored selections', async ({
|
||||
@@ -203,17 +195,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select first node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Select second node and color it differently
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorRed)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="red"]')
|
||||
.click()
|
||||
|
||||
// Select both nodes
|
||||
@@ -223,7 +215,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
])
|
||||
|
||||
// Color picker should show null/mixed state
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
})
|
||||
|
||||
@@ -232,9 +226,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// First color a node
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
// Clear selection
|
||||
@@ -244,8 +238,10 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should show the correct color
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
@@ -253,9 +249,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 74 KiB |
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -20,16 +19,10 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
const colorPickerButton = comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerButton
|
||||
)
|
||||
await colorPickerButton.click()
|
||||
|
||||
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
await colorPickerGroup
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
@@ -39,10 +39,6 @@ Prefer Vue native options when available:
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
- Exception: when third-party libraries render runtime DOM outside Vue templates
|
||||
(for example xterm internals inside PrimeVue terminal wrappers), scoped
|
||||
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
|
||||
exception is required.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@ export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.{css,vue}': (stagedFiles: string[]) => {
|
||||
const joinedPaths = toJoinedRelativePaths(stagedFiles)
|
||||
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
|
||||
},
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
@@ -27,17 +22,12 @@ export default {
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
function toJoinedRelativePaths(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) =>
|
||||
path.relative(process.cwd(), f).replace(/\\/g, '/')
|
||||
)
|
||||
return relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.5",
|
||||
"version": "1.41.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,10 @@
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
|
||||
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
|
||||
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
|
||||
"lint:no-cache": "oxlint src --type-aware && eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--default-transition-duration: 0.1s;
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
|
||||
@@ -3952,7 +3952,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
/**
|
||||
* @description The subscription billing duration
|
||||
* @enum {string}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import zipdir from 'zip-dir'
|
||||
|
||||
const sourceDir = process.argv[2] || './dist'
|
||||
const outputPath = process.argv[3] || './dist.zip'
|
||||
|
||||
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
||||
if (err) {
|
||||
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||
console.error('Error zipping "dist" directory:', err)
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||
)
|
||||
console.log('Successfully zipped "dist" directory.')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,28 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition,
|
||||
openFileInNewTab
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
const { mockIsCloud } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: false }
|
||||
}))
|
||||
let mockIsCloud = false
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
return mockIsCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
@@ -37,7 +26,7 @@ describe('downloadUtil', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
mockIsCloud = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
@@ -165,7 +154,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -184,7 +173,6 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -195,7 +183,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
@@ -209,15 +197,14 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
await Promise.resolve()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -236,7 +223,6 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -245,7 +231,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -267,7 +253,6 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -275,7 +260,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -293,7 +278,6 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -301,99 +285,6 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens URL directly when not in cloud mode', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(mockTab.close).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockTab.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
@@ -114,23 +112,14 @@ export function extractFilenameFromContentDisposition(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and return its body as a Blob.
|
||||
* Shared by download and open-in-new-tab cloud paths.
|
||||
*/
|
||||
async function fetchAsBlob(url: string): Promise<Response> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadViaBlobFetch(
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
): Promise<void> {
|
||||
const response = await fetchAsBlob(href)
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
@@ -140,44 +129,3 @@ async function downloadViaBlobFetch(
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file URL in a new browser tab.
|
||||
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||
* that would trigger an auto-download instead of displaying the file.
|
||||
*
|
||||
* Opens the tab synchronously to preserve the user-gesture context
|
||||
* (browsers block window.open after an await), then navigates it to
|
||||
* the blob URL once the fetch completes.
|
||||
*/
|
||||
export async function openFileInNewTab(url: string): Promise<void> {
|
||||
if (!isCloud) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// Open immediately to preserve user-gesture activation.
|
||||
const tab = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const response = await fetchAsBlob(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = blobUrl
|
||||
// Revoke after the tab has had time to load the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
} else {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
console.error('Failed to open image:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorOpenImage', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +103,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -195,6 +195,8 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -203,7 +205,7 @@ onUpdated(() => {
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb) {
|
||||
@@ -212,10 +214,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: calc(var(--spacing) * 8);
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
@@ -237,7 +236,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
border-radius: var(--radius-lg);
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
@@ -271,16 +270,18 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
display: flex;
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -186,19 +186,19 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
user-select: none;
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
}
|
||||
|
||||
.active-breadcrumb-item {
|
||||
|
||||
148
src/components/common/ElectronFileDownload.vue
Normal file
148
src/components/common/ElectronFileDownload.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<!-- A Electron-backed download button with a label, size hint and progress bar -->
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-action flex flex-row items-center gap-2">
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerDownload"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
>
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="downloadProgress"
|
||||
:show-value="downloadProgress > 10"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const downloadProgress = ref<number>(0)
|
||||
const status = ref<string | null>(null)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const [savePath, filename] = props.label.split('/')
|
||||
|
||||
electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
const download = downloads.find((download) => props.url === download.url)
|
||||
|
||||
if (download) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
downloadProgress.value = Number((download.progress * 100).toFixed(1))
|
||||
// @ts-expect-error fixme ts strict error
|
||||
status.value = download.status
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDownload = async () => {
|
||||
await electronDownloadStore.start({
|
||||
url: props.url,
|
||||
savePath: savePath.trim(),
|
||||
filename: filename.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
|
||||
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
</script>
|
||||
69
src/components/common/FileDownload.vue
Normal file
69
src/components/common/FileDownload.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- A file download button with a label and a size hint -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<div>
|
||||
<span :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<Message
|
||||
v-if="props.error"
|
||||
severity="error"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
}"
|
||||
>
|
||||
{{ props.error }}
|
||||
</Message>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
>
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -117,18 +117,20 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
width: 5rem;
|
||||
@apply w-20;
|
||||
}
|
||||
|
||||
.form-input :deep(.input-knob) .p-inputnumber input,
|
||||
.form-input :deep(.input-knob) .knob-part {
|
||||
width: 8rem;
|
||||
@apply w-32;
|
||||
}
|
||||
|
||||
.form-input :deep(.p-inputtext),
|
||||
.form-input :deep(.p-select) {
|
||||
width: 11rem;
|
||||
@apply w-44;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,6 +133,8 @@ const wrapperStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Chip removable @remove="emit('remove', $event)">
|
||||
<Badge size="small" :class="semanticBadgeClass">
|
||||
<Chip removable @remove="$emit('remove', $event)">
|
||||
<Badge size="small" :class="badgeClass">
|
||||
{{ badge }}
|
||||
</Badge>
|
||||
{{ text }}
|
||||
@@ -10,7 +10,6 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Chip from 'primevue/chip'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface SearchFilter {
|
||||
text: string
|
||||
@@ -19,19 +18,26 @@ export interface SearchFilter {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
const semanticClassMap: Record<string, string> = {
|
||||
'i-badge': 'bg-green-500 text-white',
|
||||
'o-badge': 'bg-red-500 text-white',
|
||||
'c-badge': 'bg-blue-500 text-white',
|
||||
's-badge': 'bg-yellow-500'
|
||||
defineProps<Omit<SearchFilter, 'id'>>()
|
||||
defineEmits(['remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
const props = defineProps<Omit<SearchFilter, 'id'>>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', event: Event): void
|
||||
}>()
|
||||
:deep(.o-badge) {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
const semanticBadgeClass = computed(() => {
|
||||
return semanticClassMap[props.badgeClass] ?? props.badgeClass
|
||||
})
|
||||
</script>
|
||||
:deep(.c-badge) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.s-badge) {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
function mountEditor(points: CurvePoint[], extraProps = {}) {
|
||||
return mount(CurveEditor, {
|
||||
props: { modelValue: points, ...extraProps }
|
||||
})
|
||||
}
|
||||
|
||||
function getCurvePath(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-testid="curve-path"]')
|
||||
}
|
||||
|
||||
describe('CurveEditor', () => {
|
||||
it('renders SVG with curve path', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
const curvePath = getCurvePath(wrapper)
|
||||
expect(curvePath.exists()).toBe(true)
|
||||
expect(curvePath.attributes('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a circle for each control point', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.7],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders histogram path when provided', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const wrapper = mountEditor(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
{ histogram }
|
||||
)
|
||||
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
|
||||
expect(histogramPath.exists()).toBe(true)
|
||||
expect(histogramPath.attributes('d')).toContain('M0,1')
|
||||
})
|
||||
|
||||
it('does not render histogram path when not provided', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty path with fewer than 2 points', () => {
|
||||
const wrapper = mountEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath(wrapper).attributes('d')).toBe('')
|
||||
})
|
||||
|
||||
it('generates path starting with M and containing L segments', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
expect(d).toMatch(/^M/)
|
||||
expect(d).toContain('L')
|
||||
})
|
||||
|
||||
it('curve path only spans the x-range of control points', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const xValues = d
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[0]))
|
||||
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
|
||||
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
|
||||
})
|
||||
|
||||
it('deletes a point on right-click but keeps minimum 2', async () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
]
|
||||
const wrapper = mountEditor(points)
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
|
||||
await wrapper.findAll('circle')[1].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
|
||||
await wrapper.findAll('circle')[0].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'h' + v"
|
||||
:x1="0"
|
||||
:y1="v"
|
||||
:x2="1"
|
||||
:y2="v"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'v' + v"
|
||||
:x1="v"
|
||||
:y1="0"
|
||||
:x2="v"
|
||||
:y2="1"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="1"
|
||||
y2="0"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
:fill="curveColor"
|
||||
fill-opacity="0.15"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
<path
|
||||
data-testid="curve-path"
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
required: true
|
||||
})
|
||||
|
||||
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
|
||||
|
||||
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
svgRef,
|
||||
modelValue
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('produces monotone output for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
})
|
||||
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps output to [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
* without overshooting (monotone property).
|
||||
*
|
||||
* Returns a function that evaluates y for any x in [0, 1].
|
||||
*/
|
||||
export function createMonotoneInterpolator(
|
||||
points: CurvePoint[]
|
||||
): (x: number) => number {
|
||||
if (points.length === 0) return () => 0
|
||||
if (points.length === 1) return () => points[0][1]
|
||||
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
const n = sorted.length
|
||||
const xs = sorted.map((p) => p[0])
|
||||
const ys = sorted.map((p) => p[1])
|
||||
|
||||
const deltas: number[] = []
|
||||
const slopes: number[] = []
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const dx = xs[i + 1] - xs[i]
|
||||
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
|
||||
}
|
||||
|
||||
slopes.push(deltas[0] ?? 0)
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (deltas[i - 1] * deltas[i] <= 0) {
|
||||
slopes.push(0)
|
||||
} else {
|
||||
slopes.push((deltas[i - 1] + deltas[i]) / 2)
|
||||
}
|
||||
}
|
||||
slopes.push(deltas[n - 2] ?? 0)
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (deltas[i] === 0) {
|
||||
slopes[i] = 0
|
||||
slopes[i + 1] = 0
|
||||
} else {
|
||||
const alpha = slopes[i] / deltas[i]
|
||||
const beta = slopes[i + 1] / deltas[i]
|
||||
const s = alpha * alpha + beta * beta
|
||||
if (s > 9) {
|
||||
const t = 3 / Math.sqrt(s)
|
||||
slopes[i] = t * alpha * deltas[i]
|
||||
slopes[i + 1] = t * beta * deltas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (x: number): number => {
|
||||
if (x <= xs[0]) return ys[0]
|
||||
if (x >= xs[n - 1]) return ys[n - 1]
|
||||
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (xs[mid] <= x) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const dx = xs[hi] - xs[lo]
|
||||
if (dx === 0) return ys[lo]
|
||||
|
||||
const t = (x - xs[lo]) / dx
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
|
||||
const h00 = 2 * t3 - 3 * t2 + 1
|
||||
const h10 = t3 - 2 * t2 + t
|
||||
const h01 = -2 * t3 + 3 * t2
|
||||
const h11 = t3 - t2
|
||||
|
||||
return (
|
||||
h00 * ys[lo] +
|
||||
h10 * dx * slopes[lo] +
|
||||
h01 * ys[hi] +
|
||||
h11 * dx * slopes[hi]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 256-bin histogram into an SVG path string.
|
||||
* Normalizes using the 99.5th percentile to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
if (!histogram.length) return ''
|
||||
|
||||
const sorted = Array.from(histogram).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const step = 1 / 255
|
||||
let d = 'M0,1'
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i * step
|
||||
const y = 1 - Math.min(1, histogram[i] / max)
|
||||
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
|
||||
}
|
||||
d += ' L1,1 Z'
|
||||
return d
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256)
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = interpolate(x)
|
||||
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
|
||||
}
|
||||
|
||||
return lut
|
||||
}
|
||||
@@ -71,30 +71,20 @@ function getDialogPt(item: {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-bottom: 0;
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: var(--p-dialog-header-padding);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: var(--p-dialog-content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.description') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="model in processedModels"
|
||||
:key="model.name"
|
||||
class="flex items-center justify-between px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-foreground"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
|
||||
>
|
||||
{{ model.badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.copyURL')"
|
||||
@click="void copyToClipboard(model.url)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalDownloadSize > 0"
|
||||
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.totalSize') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatSize(totalDownloadSize) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
|
||||
>
|
||||
{{ $t('missingModelsDialog.footerDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="hasCustomModels"
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
class="m-0 text-xs font-semibold leading-5 text-warning-background"
|
||||
>
|
||||
{{ $t('missingModelsDialog.customModelsWarning') }}
|
||||
</p>
|
||||
<p class="m-0 text-xs leading-5 text-warning-background">
|
||||
{{ $t('missingModelsDialog.customModelsInstruction') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
getBadgeLabel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
interface ProcessedModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
badgeLabel: string
|
||||
isDownloadable: boolean
|
||||
}
|
||||
|
||||
const processedModels = computed<ProcessedModel[]>(() =>
|
||||
missingModels.map((model) => ({
|
||||
name: model.name,
|
||||
url: model.url,
|
||||
directory: model.directory,
|
||||
badgeLabel: getBadgeLabel(model.directory),
|
||||
isDownloadable:
|
||||
hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
}))
|
||||
)
|
||||
|
||||
const hasCustomModels = computed(() =>
|
||||
processedModels.value.some((m) => !m.isDownloadable)
|
||||
)
|
||||
|
||||
const fileSizes = reactive(new Map<string, number>())
|
||||
|
||||
const totalDownloadSize = computed(() =>
|
||||
processedModels.value
|
||||
.filter((model) => model.isDownloadable)
|
||||
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const downloadableUrls = processedModels.value
|
||||
.filter((m) => m.isDownloadable)
|
||||
.map((m) => m.url)
|
||||
|
||||
await Promise.allSettled(
|
||||
downloadableUrls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) fileSizes.set(url, parseInt(size, 10))
|
||||
} catch {
|
||||
// Silently skip size fetch failures
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2 px-4 py-2">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainModels"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainModels">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="ml-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleAction">
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const DIALOG_KEY = 'global-missing-models-warning'
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
|
||||
})
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const downloadableModels = computed(() =>
|
||||
missingModels.filter(
|
||||
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
)
|
||||
)
|
||||
|
||||
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
|
||||
|
||||
const hasCustom = computed(
|
||||
() => downloadableModels.value.length < missingModels.length
|
||||
)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (hasDownloadable.value && hasCustom.value)
|
||||
return t('missingModelsDialog.downloadAvailable')
|
||||
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
|
||||
return t('missingModelsDialog.gotIt')
|
||||
})
|
||||
|
||||
function handleAction() {
|
||||
if (hasDownloadable.value) {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, paths)
|
||||
}
|
||||
}
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('missingModelsDialog.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
177
src/components/dialog/content/MissingModelsWarning.vue
Normal file
177
src/components/dialog/content/MissingModelsWarning.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
<Suspense v-if="isDesktop">
|
||||
<ElectronFileDownload
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</Suspense>
|
||||
<FileDownload
|
||||
v-else
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</template>
|
||||
</ListBox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
const allowedSources = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:' // Included for testing usage only
|
||||
]
|
||||
const allowedSuffixes = ['.safetensors', '.sft']
|
||||
// Models that fail above conditions but are still allowed
|
||||
const whiteListedUrls = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
interface ModelInfo {
|
||||
name: string
|
||||
directory: string
|
||||
url: string
|
||||
downloading?: boolean
|
||||
completed?: boolean
|
||||
progress?: number
|
||||
error?: string
|
||||
folder_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const paths = props.paths[model.directory]
|
||||
if (!paths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: 'Invalid directory specified (does this require custom nodes?)'
|
||||
}
|
||||
}
|
||||
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
|
||||
downloading: false,
|
||||
completed: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
folder_path: paths[0]
|
||||
}
|
||||
modelDownloads.value[model.name] = downloadInfo
|
||||
if (!whiteListedUrls.has(model.url)) {
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: model.url,
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
downloading: downloadInfo.downloading,
|
||||
completed: downloadInfo.completed,
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
folderPath: downloadInfo.folder_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (doNotAskAgain.value) {
|
||||
await useSettingStore().set(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-models {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:'
|
||||
] as const
|
||||
|
||||
const ALLOWED_SUFFIXES = [
|
||||
'.safetensors',
|
||||
'.sft',
|
||||
'.ckpt',
|
||||
'.pth',
|
||||
'.pt'
|
||||
] as const
|
||||
|
||||
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
const DIRECTORY_BADGE_MAP = {
|
||||
vae: 'VAE',
|
||||
diffusion_models: 'DIFFUSION',
|
||||
text_encoders: 'TEXT ENCODER',
|
||||
loras: 'LORA',
|
||||
checkpoints: 'CHECKPOINT'
|
||||
} as const
|
||||
|
||||
export interface ModelWithUrl {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function isModelDownloadable(model: ModelWithUrl): boolean {
|
||||
if (WHITE_LISTED_URLS.has(model.url)) return true
|
||||
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
|
||||
return false
|
||||
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasValidDirectory(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): boolean {
|
||||
return !!paths[model.directory]
|
||||
}
|
||||
|
||||
export function getBadgeLabel(directory: string): string {
|
||||
if (directory in DIRECTORY_BADGE_MAP) {
|
||||
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
|
||||
}
|
||||
return directory.toUpperCase()
|
||||
}
|
||||
|
||||
export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
link.download = model.name
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
const modelPaths = paths[model.directory]
|
||||
if (modelPaths?.[0]) {
|
||||
void useElectronDownloadStore().start({
|
||||
url: model.url,
|
||||
savePath: modelPaths[0],
|
||||
filename: model.name
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@
|
||||
}"
|
||||
@row-dblclick="editKeybinding($event.data)"
|
||||
>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<Column field="actions" header="">
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row">
|
||||
<div class="actions invisible flex flex-row">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@@ -56,7 +56,6 @@
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="truncate" :title="slotProps.data.id">
|
||||
@@ -64,11 +63,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="keybinding"
|
||||
:header="$t('g.keybinding')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<Column field="keybinding" :header="$t('g.keybinding')">
|
||||
<template #body="slotProps">
|
||||
<KeyComboDisplay
|
||||
v-if="slotProps.data.keybinding"
|
||||
@@ -80,11 +75,7 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<Column field="source" :header="$t('g.source')">
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
@@ -302,3 +293,17 @@ async function resetAllKeybindings() {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-datatable-tbody) > tr > td {
|
||||
@apply p-1;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-row-selected) .actions,
|
||||
:deep(.p-datatable-selectable-row:hover) .actions {
|
||||
@apply visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,17 +98,16 @@ describe('SignInForm', () => {
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
|
||||
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
|
||||
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
@@ -153,7 +152,7 @@ describe('SignInForm', () => {
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
|
||||
@@ -34,13 +34,10 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
:class="
|
||||
cn('text-base font-medium text-muted select-none', {
|
||||
'cursor-not-allowed opacity-50':
|
||||
!$form.email?.value || $form.email?.invalid,
|
||||
'cursor-pointer': $form.email?.value && !$form.email?.invalid
|
||||
})
|
||||
"
|
||||
class="cursor-pointer text-base font-medium text-muted select-none"
|
||||
:class="{
|
||||
'text-link-disabled': !$form.email?.value || $form.email?.invalid
|
||||
}"
|
||||
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
@@ -92,7 +89,6 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
|
||||
import { signInSchema } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
@@ -130,3 +126,11 @@ const handleForgotPassword = async (
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,38 +8,20 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockWorkflow(
|
||||
overrides: Partial<LoadedComfyWorkflow> = {}
|
||||
): LoadedComfyWorkflow {
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: 'workflows/color-picker-test.json',
|
||||
modified: 0,
|
||||
size: 0
|
||||
})
|
||||
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
return {
|
||||
changeTracker: {
|
||||
checkState: vi.fn() as Mock
|
||||
}
|
||||
)
|
||||
|
||||
const workflowOverrides = {
|
||||
changeTracker,
|
||||
},
|
||||
...overrides
|
||||
} satisfies Partial<LoadedComfyWorkflow>
|
||||
|
||||
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
|
||||
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
// Mock the litegraph module
|
||||
@@ -128,14 +110,12 @@ describe('ColorPickerButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
|
||||
await button.trigger('click')
|
||||
const picker = wrapper.findComponent({ name: 'SelectButton' })
|
||||
expect(picker.exists()).toBe(true)
|
||||
expect(picker.findAll('button').length).toBeGreaterThan(0)
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,17 +11,13 @@
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i
|
||||
class="pi pi-circle-fill"
|
||||
data-testid="color-picker-current-color"
|
||||
:style="{ color: currentColor ?? '' }"
|
||||
/>
|
||||
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
class="absolute -top-10 left-1/2 -translate-x-1/2"
|
||||
class="color-picker-container absolute -top-10 left-1/2"
|
||||
>
|
||||
<SelectButton
|
||||
:model-value="selectedColorOption"
|
||||
@@ -163,7 +159,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.color-picker-container {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
padding: calc(var(--spacing) * 2) var(--spacing);
|
||||
@apply py-2 px-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
<div
|
||||
v-show="widgetState.visible"
|
||||
ref="widgetElement"
|
||||
class="dom-widget h-full w-full"
|
||||
class="dom-widget"
|
||||
:title="tooltip"
|
||||
:style="style"
|
||||
>
|
||||
<component
|
||||
:is="widget.component"
|
||||
v-if="isComponentWidget(widget)"
|
||||
class="h-full w-full"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
@@ -175,8 +174,6 @@ const mountElementIfVisible = () => {
|
||||
if (widgetElement.value.contains(widget.element)) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.element.classList.add('h-full', 'w-full')
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
|
||||
@@ -199,3 +196,11 @@ watch(
|
||||
|
||||
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.dom-widget > * {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div
|
||||
v-else
|
||||
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
|
||||
>
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
@@ -25,52 +22,48 @@
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,59 +100,39 @@ const outputList = computed(() =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm overflow-visible;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.fallback-content h4 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h5),
|
||||
.fallback-content h5 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
@@ -182,8 +155,7 @@ const outputList = computed(() =>
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
@@ -194,42 +166,36 @@ const outputList = computed(() =>
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
margin-top: 0;
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fallback-content table {
|
||||
width: 100%;
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
@@ -249,22 +215,16 @@ const outputList = computed(() =>
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
margin-block: calc(var(--spacing) * 4);
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 4);
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
@apply bg-transparent p-0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const hasMissingNodeSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingNodeGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
|
||||
@@ -17,24 +17,26 @@
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
<div class="flex items-center shrink-0">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0 h-8"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
|
||||
79
src/components/rightSidePanel/errors/MissingNodeCard.vue
Normal file
79
src/components/rightSidePanel/errors/MissingNodeCard.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
|
||||
{{
|
||||
isCloud
|
||||
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||
}}
|
||||
</p>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="primary"
|
||||
:disabled="isRestarting"
|
||||
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
|
||||
<span class="truncate min-w-0">{{
|
||||
t('rightSidePanel.missingNodePacks.applyChanges')
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
openManagerInfo: [packId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { isRestarting, applyChanges } = useApplyChanges()
|
||||
const { shouldShowManagerButtons } = useManagerState()
|
||||
|
||||
/**
|
||||
* Show Apply Changes when any pack from the error group is already installed
|
||||
* on disk but ComfyUI hasn't restarted yet to load it.
|
||||
* This is server-state based → persists across browser refreshes.
|
||||
*/
|
||||
const hasInstalledPacksPendingRestart = computed(() =>
|
||||
props.missingPackGroups.some(
|
||||
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
|
||||
)
|
||||
)
|
||||
</script>
|
||||
240
src/components/rightSidePanel/errors/MissingPackGroupRow.vue
Normal file
240
src/components/rightSidePanel/errors/MissingPackGroupRow.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full mb-2">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
|
||||
/>
|
||||
<p
|
||||
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack') }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="flex flex-col gap-0.5 pl-2 mb-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none',
|
||||
comfyManagerStore.isPackInstalled(group.packId)
|
||||
? 'bg-secondary-background opacity-60 cursor-not-allowed'
|
||||
: 'bg-secondary-background-hover cursor-pointer hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
|
||||
/>
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background-hover cursor-pointer hover:bg-secondary-background-selected transition-colors select-none"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const props = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
openManagerInfo: [packId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { shouldShowManagerButtons, openManager } = useManagerState()
|
||||
|
||||
const nodePack = computed(() => {
|
||||
if (!props.group.packId) return null
|
||||
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
|
||||
})
|
||||
|
||||
const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!props.group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
if (typeof nodeType === 'string') return nodeType
|
||||
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
|
||||
}
|
||||
|
||||
function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn()
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="group.type === 'missing_node' ? 'lg' : 'default'"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
@@ -39,17 +40,46 @@
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.cards.length > 1"
|
||||
v-if="group.type !== 'missing_node' && group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-else class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
@@ -108,12 +138,18 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -122,6 +158,11 @@ const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -136,7 +177,9 @@ const {
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
/**
|
||||
@@ -167,6 +210,19 @@ function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface ErrorCardData {
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
export type ErrorGroupType = 'execution' | 'missing_node' | 'missing_model'
|
||||
|
||||
export interface ErrorGroup {
|
||||
type: ErrorGroupType
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -20,7 +20,13 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type {
|
||||
ErrorCardData,
|
||||
ErrorGroup,
|
||||
ErrorGroupType,
|
||||
ErrorItem
|
||||
} from './types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
@@ -32,7 +38,23 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'server_error'
|
||||
])
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/**
|
||||
* A group of missing node types belonging to the same node pack.
|
||||
*/
|
||||
export interface MissingPackGroup {
|
||||
/** Registry pack ID (cnrId). null = could not be resolved. */
|
||||
packId: string | null
|
||||
/** Missing node types belonging to this pack. */
|
||||
nodeTypes: MissingNodeType[]
|
||||
/** True while async pack inference is still in progress for this group. */
|
||||
isResolving: boolean
|
||||
}
|
||||
|
||||
interface GroupEntry {
|
||||
type: ErrorGroupType
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
@@ -72,11 +94,12 @@ function resolveNodeInfo(nodeId: string) {
|
||||
function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
title: string,
|
||||
priority = 1
|
||||
priority = 1,
|
||||
type: ErrorGroupType = 'execution'
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(title)
|
||||
if (!entry) {
|
||||
entry = { priority, cards: new Map() }
|
||||
entry = { type, priority, cards: new Map() }
|
||||
groupsMap.set(title, entry)
|
||||
}
|
||||
return entry.cards
|
||||
@@ -137,6 +160,7 @@ function addCardErrorToGroup(
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([title, groupData]) => ({
|
||||
type: groupData.type,
|
||||
title,
|
||||
cards: Array.from(groupData.cards.values()),
|
||||
priority: groupData.priority
|
||||
@@ -197,6 +221,7 @@ export function useErrorGroups(
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const selectedNodeInfo = computed(() => {
|
||||
@@ -237,6 +262,19 @@ export function useErrorGroups(
|
||||
return map
|
||||
})
|
||||
|
||||
const missingNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
const nodeId = String(nodeType.nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (node) map.set(nodeId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
@@ -343,6 +381,126 @@ export function useErrorGroups(
|
||||
)
|
||||
}
|
||||
|
||||
// Async pack-ID resolution for missing node types that lack a cnrId
|
||||
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||
|
||||
const pendingTypes = computed(() =>
|
||||
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(n): n is Exclude<MissingNodeType, string> =>
|
||||
typeof n !== 'string' && !n.cnrId
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
pendingTypes,
|
||||
async (pending) => {
|
||||
const toResolve = pending.filter(
|
||||
(n) => !asyncResolvedIds.value.has(n.type)
|
||||
)
|
||||
if (!toResolve.length) return
|
||||
|
||||
const updated = new Map(asyncResolvedIds.value)
|
||||
for (const nodeType of toResolve) {
|
||||
updated.set(nodeType.type, RESOLVING)
|
||||
}
|
||||
asyncResolvedIds.value = updated
|
||||
|
||||
for (const nodeType of toResolve) {
|
||||
const pack = await inferPackFromNodeName.call(nodeType.type)
|
||||
asyncResolvedIds.value = new Map(asyncResolvedIds.value).set(
|
||||
nodeType.type,
|
||||
pack?.id ?? null
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<
|
||||
string | null,
|
||||
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||
>()
|
||||
const resolvingKeys = new Set<string | null>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
let packId: string | null
|
||||
|
||||
if (typeof nodeType === 'string') {
|
||||
packId = null
|
||||
} else if (nodeType.cnrId) {
|
||||
packId = nodeType.cnrId
|
||||
} else {
|
||||
const resolved = asyncResolvedIds.value.get(nodeType.type)
|
||||
if (resolved === undefined || resolved === RESOLVING) {
|
||||
packId = null
|
||||
resolvingKeys.add(null)
|
||||
} else {
|
||||
packId = resolved
|
||||
}
|
||||
}
|
||||
|
||||
const existing = map.get(packId)
|
||||
if (existing) {
|
||||
existing.nodeTypes.push(nodeType)
|
||||
} else {
|
||||
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of resolvingKeys) {
|
||||
const group = map.get(key)
|
||||
if (group) group.isResolving = true
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([packIdA], [packIdB]) => {
|
||||
// null (Unknown Pack) always goes last
|
||||
if (packIdA === null) return 1
|
||||
if (packIdB === null) return -1
|
||||
return packIdA.localeCompare(packIdB)
|
||||
})
|
||||
.map(([packId, { nodeTypes, isResolving }]) => ({
|
||||
packId,
|
||||
nodeTypes: [...nodeTypes].sort((a, b) => {
|
||||
const typeA = typeof a === 'string' ? a : a.type
|
||||
const typeB = typeof b === 'string' ? b : b.type
|
||||
const typeCmp = typeA.localeCompare(typeB)
|
||||
if (typeCmp !== 0) return typeCmp
|
||||
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
|
||||
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
|
||||
return idA.localeCompare(idB, undefined, { numeric: true })
|
||||
}),
|
||||
isResolving
|
||||
}))
|
||||
})
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = executionErrorStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'missing_node' as const,
|
||||
title: error.message,
|
||||
cards: [
|
||||
{
|
||||
id: '__missing_nodes__',
|
||||
title: error.message,
|
||||
errors: [
|
||||
{
|
||||
message: error.message
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -350,7 +508,7 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -360,9 +518,11 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return isSingleNodeSelected.value
|
||||
const executionGroups = isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -389,6 +549,8 @@ export function useErrorGroups(
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
groupedErrorMessages
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ const {
|
||||
label,
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
size = 'default',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
size?: 'default' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
@@ -39,7 +41,8 @@ const tooltipConfig = computed(() => {
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
size === 'lg' ? 'min-h-16' : 'min-h-12',
|
||||
!disabled && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -131,6 +131,12 @@ const nodeHasError = computed(() => {
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const showSeeError = computed(
|
||||
() =>
|
||||
nodeHasError.value &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
@@ -194,6 +200,7 @@ defineExpose({
|
||||
:enable-empty-state
|
||||
:disabled="isEmpty"
|
||||
:tooltip
|
||||
:size="showSeeError ? 'lg' : 'default'"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
@@ -223,13 +230,10 @@ defineExpose({
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
nodeHasError &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
"
|
||||
v-if="showSeeError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 rounded-lg text-sm h-8"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="_content">
|
||||
<SelectButton
|
||||
v-model="selectedFilter"
|
||||
class="filter-type-select"
|
||||
@@ -16,7 +16,7 @@
|
||||
auto-filter-focus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-end pt-4">
|
||||
<div class="_footer">
|
||||
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,3 +67,15 @@ const submit = () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
._content {
|
||||
@apply flex flex-col space-y-2;
|
||||
}
|
||||
|
||||
._footer {
|
||||
@apply flex flex-col pt-4 items-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,6 +255,8 @@ onMounted(() => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.floating-sidebar {
|
||||
padding: var(--sidebar-padding);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:aria-label="computedTooltip"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<div class="side-bar-button-content flex flex-col items-center gap-2">
|
||||
<div class="side-bar-button-content">
|
||||
<slot name="icon">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
<i
|
||||
@@ -40,11 +40,9 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-[10px]"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
st(label, label)
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -106,6 +104,8 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.side-bar-button {
|
||||
width: var(--sidebar-width);
|
||||
height: var(--sidebar-item-height);
|
||||
@@ -117,7 +117,12 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
height: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.side-bar-button-content {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.side-bar-button-label {
|
||||
@apply text-[10px] text-center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<Toolbar
|
||||
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
|
||||
:pt="sidebarPt"
|
||||
>
|
||||
<template #start>
|
||||
<span class="truncate font-bold" :title="props.title">
|
||||
@@ -21,7 +20,7 @@
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
>
|
||||
<slot name="tool-buttons" />
|
||||
</div>
|
||||
@@ -55,10 +54,19 @@ const props = defineProps<{
|
||||
title: string
|
||||
class?: string
|
||||
}>()
|
||||
const sidebarPt = {
|
||||
start: 'min-w-0 flex-1 overflow-hidden'
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
provide(SidebarContainerKey, containerRef)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-toolbar-end) .p-button {
|
||||
@apply py-1 2xl:py-2;
|
||||
}
|
||||
|
||||
:deep(.p-toolbar-start) {
|
||||
@apply min-w-0 flex-1 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="node-lib-node-container h-full w-full"
|
||||
class="node-lib-node-container"
|
||||
data-testid="node-tree-leaf"
|
||||
:data-node-name="nodeDef.display_name"
|
||||
>
|
||||
@@ -206,3 +206,11 @@ onUnmounted(() => {
|
||||
nodeContentElement.value?.removeEventListener('mouseleave', handleMouseLeave)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.node-lib-node-container {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -113,13 +113,12 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
const mockSubscriptionDialogShow = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: vi.fn(() => ({
|
||||
show: vi.fn(),
|
||||
showPricingTable: mockShowPricingTable,
|
||||
show: mockSubscriptionDialogShow,
|
||||
hide: vi.fn()
|
||||
}))
|
||||
})
|
||||
@@ -319,8 +318,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
|
||||
await plansPricingItem.trigger('click')
|
||||
|
||||
// Verify showPricingTable was called
|
||||
expect(mockShowPricingTable).toHaveBeenCalled()
|
||||
// Verify subscription dialog show was called
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
|
||||
@@ -195,10 +195,7 @@ const formattedBalance = computed(() => {
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
return (
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -208,7 +205,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.show()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -140,65 +140,54 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
@apply flex flex-col rounded-xl overflow-hidden;
|
||||
max-width: var(--popover-width);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail {
|
||||
position: relative;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
@apply relative p-2;
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail img {
|
||||
box-shadow: var(--shadow-md);
|
||||
@apply shadow-md;
|
||||
background-color: color-mix(in srgb, var(--comfy-menu-bg) 70%, black);
|
||||
}
|
||||
|
||||
.dark-theme .workflow-preview-thumbnail img {
|
||||
box-shadow: var(--shadow-lg);
|
||||
@apply shadow-lg;
|
||||
}
|
||||
|
||||
.workflow-preview-footer {
|
||||
padding-top: calc(var(--spacing) * 1);
|
||||
padding-right: calc(var(--spacing) * 3);
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
@apply pt-1 pb-2 px-3;
|
||||
}
|
||||
|
||||
.workflow-preview-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
font-weight: var(--font-weight-medium);
|
||||
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-popover-fade {
|
||||
--p-popover-background: transparent;
|
||||
--p-popover-content-padding: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: transparent;
|
||||
box-shadow: var(--shadow-lg);
|
||||
@apply bg-transparent rounded-xl shadow-lg;
|
||||
transition: opacity 0.15s ease-out !important;
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover-flipped {
|
||||
transform: translateY(-100%);
|
||||
@apply -translate-y-full;
|
||||
}
|
||||
|
||||
.dark-theme .workflow-popover-fade {
|
||||
box-shadow: var(--shadow-2xl);
|
||||
@apply shadow-2xl;
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover::after,
|
||||
|
||||
@@ -300,72 +300,63 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.workflow-tabs-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
border: 0;
|
||||
border-right-style: solid;
|
||||
border-right-width: 1px;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
@apply p-0 bg-transparent rounded-none shrink relative border-0 border-r border-solid;
|
||||
border-right-color: var(--border-color);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.overflow-arrow {
|
||||
border-radius: 0;
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
@apply px-2 rounded-none;
|
||||
}
|
||||
|
||||
.overflow-arrow[disabled] {
|
||||
opacity: 0.25;
|
||||
@apply opacity-25;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton > .p-togglebutton-content) {
|
||||
max-width: 100%;
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tab) {
|
||||
max-width: 100%;
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton::before) {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:first-child) {
|
||||
border-left-style: solid;
|
||||
border-left-width: 1px;
|
||||
@apply border-l border-solid;
|
||||
border-left-color: var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:not(:first-child)) {
|
||||
border-left-width: 0;
|
||||
@apply border-l-0;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton.p-togglebutton-checked) {
|
||||
height: 100%;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 1px;
|
||||
@apply border-b border-solid h-full;
|
||||
border-bottom-color: var(--p-button-text-primary-color);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:not(.p-togglebutton-checked)) {
|
||||
opacity: 0.75;
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton-checked) .close-button,
|
||||
:deep(.p-togglebutton:hover) .close-button {
|
||||
visibility: visible;
|
||||
@apply visible;
|
||||
}
|
||||
|
||||
:deep(.p-scrollpanel-content) {
|
||||
height: 100%;
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tabs) {
|
||||
@@ -375,12 +366,11 @@ onUpdated(() => {
|
||||
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
|
||||
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
|
||||
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
|
||||
opacity: 0.5;
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
:deep(.p-selectbutton) {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
@apply rounded-none h-full;
|
||||
}
|
||||
|
||||
.workflow-tabs-container-desktop {
|
||||
@@ -388,7 +378,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
.window-actions-spacer {
|
||||
flex: auto;
|
||||
@apply flex-auto;
|
||||
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
|
||||
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
|
||||
min-width: var(--window-actions-spacer-width);
|
||||
|
||||
@@ -70,11 +70,6 @@ export interface BillingState {
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
*/
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -120,8 +120,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
@@ -240,7 +238,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
|
||||
@@ -40,7 +40,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
@@ -86,10 +85,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
// Re-fetch balance if free tier credits were just lazily granted
|
||||
if (isFreeTier.value && balance.value?.amountMicros === 0) {
|
||||
await fetchBalance()
|
||||
}
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
@@ -178,7 +173,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useImageMenuOptions() {
|
||||
if (!img) return
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
void openFileInNewTab(url.toString())
|
||||
window.open(url.toString(), '_blank')
|
||||
}
|
||||
|
||||
const copyImage = async (node: LGraphNode) => {
|
||||
|
||||
95
src/composables/useCivitaiModel.ts
Normal file
95
src/composables/useCivitaiModel.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ModelType =
|
||||
| 'Checkpoint'
|
||||
| 'TextualInversion'
|
||||
| 'Hypernetwork'
|
||||
| 'AestheticGradient'
|
||||
| 'LORA'
|
||||
| 'Controlnet'
|
||||
| 'Poses'
|
||||
|
||||
interface CivitaiFileMetadata {
|
||||
fp?: 'fp16' | 'fp32'
|
||||
size?: 'full' | 'pruned'
|
||||
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
|
||||
}
|
||||
|
||||
interface CivitaiModelFile {
|
||||
name: string
|
||||
id: number
|
||||
sizeKB: number
|
||||
type: string
|
||||
downloadUrl: string
|
||||
metadata: CivitaiFileMetadata
|
||||
}
|
||||
|
||||
interface CivitaiModel {
|
||||
name: string
|
||||
type: ModelType
|
||||
}
|
||||
|
||||
interface CivitaiModelVersionResponse {
|
||||
id: number
|
||||
name: string
|
||||
model: CivitaiModel
|
||||
modelId: number
|
||||
files: CivitaiModelFile[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to manage Civitai model
|
||||
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
|
||||
* @see https://developer.civitai.com/docs/api/public-rest
|
||||
* @example
|
||||
* const { fileSize, isLoading, error, modelData } =
|
||||
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
|
||||
*/
|
||||
export function useCivitaiModel(url: string) {
|
||||
const createModelVersionUrl = (modelId: string): string =>
|
||||
`https://civitai.com/api/v1/model-versions/${modelId}`
|
||||
|
||||
const extractModelIdFromUrl = (): string | null => {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.pathname.split('/').pop() || null
|
||||
}
|
||||
|
||||
const fetchModelData =
|
||||
async (): Promise<CivitaiModelVersionResponse | null> => {
|
||||
const modelId = extractModelIdFromUrl()
|
||||
if (!modelId) return null
|
||||
|
||||
const apiUrl = createModelVersionUrl(modelId)
|
||||
const res = await fetch(apiUrl)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const findMatchingFileSize = (): number | null => {
|
||||
const matchingFile = modelData.value?.files?.find(
|
||||
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
|
||||
)
|
||||
|
||||
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
|
||||
}
|
||||
|
||||
const {
|
||||
state: modelData,
|
||||
isLoading,
|
||||
error
|
||||
} = useAsyncState(fetchModelData, null, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
const fileSize = computed(() =>
|
||||
!isLoading.value ? findMatchingFileSize() : null
|
||||
)
|
||||
|
||||
return {
|
||||
fileSize,
|
||||
isLoading,
|
||||
error,
|
||||
modelData
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
modelValue: Ref<CurvePoint[]>
|
||||
}
|
||||
|
||||
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
const dragIndex = ref(-1)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
const curvePath = computed(() => {
|
||||
const points = modelValue.value
|
||||
if (points.length < 2) return ''
|
||||
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
const segments = 128
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = xMin + (xMax - xMin) * (i / segments)
|
||||
const y = 1 - interpolate(x)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
|
||||
function svgCoords(e: PointerEvent): [number, number] {
|
||||
const svg = svgRef.value
|
||||
if (!svg) return [0, 0]
|
||||
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return [0, 0]
|
||||
|
||||
const svgPt = new DOMPoint(e.clientX, e.clientY).matrixTransform(
|
||||
ctm.inverse()
|
||||
)
|
||||
return [
|
||||
Math.max(0, Math.min(1, svgPt.x)),
|
||||
Math.max(0, Math.min(1, 1 - svgPt.y))
|
||||
]
|
||||
}
|
||||
|
||||
function findNearestPoint(x: number, y: number): number {
|
||||
const threshold2 = 0.04 * 0.04
|
||||
let nearest = -1
|
||||
let minDist2 = threshold2
|
||||
for (let i = 0; i < modelValue.value.length; i++) {
|
||||
const dx = modelValue.value[i][0] - x
|
||||
const dy = modelValue.value[i][1] - y
|
||||
const dist2 = dx * dx + dy * dy
|
||||
if (dist2 < minDist2) {
|
||||
minDist2 = dist2
|
||||
nearest = i
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function handleSvgPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
|
||||
const [x, y] = svgCoords(e)
|
||||
|
||||
const nearby = findNearestPoint(x, y)
|
||||
if (nearby >= 0) {
|
||||
startDrag(nearby, e)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.ctrlKey) return
|
||||
|
||||
const newPoint: CurvePoint = [x, y]
|
||||
const newPoints: CurvePoint[] = [...modelValue.value, newPoint]
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
|
||||
startDrag(newPoints.indexOf(newPoint), e)
|
||||
}
|
||||
|
||||
function startDrag(index: number, e: PointerEvent) {
|
||||
cleanupDrag?.()
|
||||
|
||||
if (e.button === 2 || (e.button === 0 && e.ctrlKey)) {
|
||||
if (modelValue.value.length > 2) {
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints.splice(index, 1)
|
||||
modelValue.value = newPoints
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dragIndex.value = index
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
|
||||
svg.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (dragIndex.value < 0) return
|
||||
const [x, y] = svgCoords(ev)
|
||||
const movedPoint: CurvePoint = [x, y]
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints[dragIndex.value] = movedPoint
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
dragIndex.value = newPoints.indexOf(movedPoint)
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (dragIndex.value < 0) return
|
||||
dragIndex.value = -1
|
||||
svg.removeEventListener('pointermove', onMove)
|
||||
svg.removeEventListener('pointerup', endDrag)
|
||||
svg.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
svg.addEventListener('pointermove', onMove)
|
||||
svg.addEventListener('pointerup', endDrag)
|
||||
svg.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
curvePath,
|
||||
handleSvgPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
67
src/composables/useDownload.ts
Normal file
67
src/composables/useDownload.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCivitaiModel } from '@/composables/useCivitaiModel'
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
|
||||
export function useDownload(url: string, fileName?: string) {
|
||||
const fileSize = ref<number | null>(null)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
const setFileSize = (size: number) => {
|
||||
fileSize.value = size
|
||||
}
|
||||
|
||||
const fetchFileSize = async () => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) throw new Error('Failed to fetch file size')
|
||||
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) {
|
||||
setFileSize(parseInt(size))
|
||||
} else {
|
||||
console.error('"content-length" header not found')
|
||||
return null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file size:', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download
|
||||
*/
|
||||
const triggerBrowserDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
if (url.includes('huggingface.co') && error.value) {
|
||||
// If model is a gated HF model, send user to the repo page so they can sign in first
|
||||
link.href = downloadUrlToHfRepoUrl(url)
|
||||
} else {
|
||||
link.href = url
|
||||
link.download = fileName || url.split('/').pop() || 'download'
|
||||
}
|
||||
link.target = '_blank' // Opens in new tab if download attribute is not supported
|
||||
link.rel = 'noopener noreferrer' // Security best practice for _blank links
|
||||
link.click()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isCivitaiModelUrl(url)) {
|
||||
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
|
||||
whenever(civitaiSize, setFileSize)
|
||||
// Try falling back to normal fetch if using Civitai API fails
|
||||
whenever(civitaiErr, fetchFileSize, { once: true })
|
||||
} else {
|
||||
// Fetch file size in the background
|
||||
void fetchFileSize()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
triggerBrowserDownload,
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
import MissingModelsContent from '@/components/dialog/content/MissingModelsContent.vue'
|
||||
import MissingModelsFooter from '@/components/dialog/content/MissingModelsFooter.vue'
|
||||
import MissingModelsHeader from '@/components/dialog/content/MissingModelsHeader.vue'
|
||||
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -16,14 +14,11 @@ export function useMissingModelsDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(props: ComponentAttrs<typeof MissingModelsContent>) {
|
||||
function show(props: ComponentAttrs<typeof MissingModelsWarning>) {
|
||||
showSmallLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
headerComponent: MissingModelsHeader,
|
||||
footerComponent: MissingModelsFooter,
|
||||
component: MissingModelsContent,
|
||||
props,
|
||||
footerProps: props
|
||||
component: MissingModelsWarning,
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,14 @@ import type {
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import {
|
||||
createNode,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isVideoNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { createNode, isImageNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
cloneDataTransfer,
|
||||
pasteAudioNode,
|
||||
pasteAudioNodes,
|
||||
pasteImageNode,
|
||||
pasteImageNodes,
|
||||
pasteVideoNode,
|
||||
pasteVideoNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
|
||||
@@ -46,13 +38,6 @@ function createAudioFile(
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createVideoFile(
|
||||
name: string = 'test.mp4',
|
||||
type: string = 'video/mp4'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createDataTransfer(files: File[] = []): DataTransfer {
|
||||
const dataTransfer = new DataTransfer()
|
||||
files.forEach((file) => dataTransfer.items.add(file))
|
||||
@@ -218,198 +203,6 @@ describe('pasteImageNodes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create new LoadAudio node when no audio node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing audio node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should filter non-audio items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const audioFile = createAudioFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, audioFile])
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(audioFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([audioFile])
|
||||
})
|
||||
|
||||
it('should do nothing when no audio files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
await pasteAudioNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple audio files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1)
|
||||
.mockResolvedValueOnce(mockNode2)
|
||||
|
||||
const file1 = createAudioFile('file1.mp3')
|
||||
const file2 = createAudioFile('file2.wav', 'audio/wav')
|
||||
|
||||
const result = await pasteAudioNodes(mockCanvas, [file1, file2])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadAudio')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadAudio')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await pasteAudioNodes(mockCanvas, [])
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle single audio file', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createAudioFile()
|
||||
const result = await pasteAudioNodes(mockCanvas, [file])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create new LoadVideo node when no video node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing video node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should filter non-video items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const videoFile = createVideoFile()
|
||||
const imageFile = createImageFile()
|
||||
const dataTransfer = createDataTransfer([imageFile, videoFile])
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(videoFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([videoFile])
|
||||
})
|
||||
|
||||
it('should do nothing when no video files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
await pasteVideoNode(mockCanvas, dataTransfer.items, mockNode)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple video files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1)
|
||||
.mockResolvedValueOnce(mockNode2)
|
||||
|
||||
const file1 = createVideoFile('file1.mp4')
|
||||
const file2 = createVideoFile('file2.webm', 'video/webm')
|
||||
|
||||
const result = await pasteVideoNodes(mockCanvas, [file1, file2])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadVideo')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadVideo')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await pasteVideoNodes(mockCanvas, [])
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle single video file', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const file = createVideoFile()
|
||||
const result = await pasteVideoNodes(mockCanvas, [file])
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -437,9 +230,9 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle audio paste using createNode helper', async () => {
|
||||
it('should handle audio paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
@@ -449,68 +242,7 @@ describe('usePaste', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should paste audio onto selected LoadAudio node', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isAudioNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle video paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should paste video onto selected LoadVideo node', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isVideoNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
@@ -541,7 +273,7 @@ describe('usePaste', () => {
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -112,68 +113,6 @@ export async function pasteImageNodes(
|
||||
return nodes
|
||||
}
|
||||
|
||||
export async function pasteAudioNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
audioNode: LGraphNode | null = null
|
||||
): Promise<LGraphNode | null> {
|
||||
if (!audioNode) {
|
||||
audioNode = await createNode(canvas, 'LoadAudio')
|
||||
}
|
||||
pasteItemsOnNode(items, audioNode, 'audio')
|
||||
return audioNode
|
||||
}
|
||||
|
||||
export async function pasteAudioNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const node = await pasteAudioNode(canvas, transfer.items)
|
||||
|
||||
if (node) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
export async function pasteVideoNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
videoNode: LGraphNode | null = null
|
||||
): Promise<LGraphNode | null> {
|
||||
if (!videoNode) {
|
||||
videoNode = await createNode(canvas, 'LoadVideo')
|
||||
}
|
||||
pasteItemsOnNode(items, videoNode, 'video')
|
||||
return videoNode
|
||||
}
|
||||
|
||||
export async function pasteVideoNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const node = await pasteVideoNode(canvas, transfer.items)
|
||||
|
||||
if (node) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
||||
*/
|
||||
@@ -193,6 +132,7 @@ export const usePaste = () => {
|
||||
const { canvas } = canvasStore
|
||||
if (!canvas) return
|
||||
|
||||
const { graph } = canvas
|
||||
let data: DataTransfer | string | null = e.clipboardData
|
||||
if (!data) throw new Error('No clipboard data on clipboard event')
|
||||
data = cloneDataTransfer(data)
|
||||
@@ -206,9 +146,7 @@ export const usePaste = () => {
|
||||
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
|
||||
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
|
||||
|
||||
const audioNode: LGraphNode | null = isAudioNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
|
||||
const imageNode: LGraphNode | null = isImageNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
@@ -222,10 +160,24 @@ export const usePaste = () => {
|
||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
await pasteVideoNode(canvas as LGraphCanvas, items, videoNode)
|
||||
return
|
||||
if (!videoNode) {
|
||||
// No video node selected: add a new one
|
||||
// TODO: when video node exists
|
||||
} else {
|
||||
pasteItemsOnNode(items, videoNode, 'video')
|
||||
return
|
||||
}
|
||||
} else if (item.type.startsWith('audio/')) {
|
||||
await pasteAudioNode(canvas as LGraphCanvas, items, audioNode)
|
||||
if (!audioNode) {
|
||||
// No audio node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadAudio')
|
||||
if (newNode) {
|
||||
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
|
||||
audioNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
}
|
||||
pasteItemsOnNode(items, audioNode, 'audio')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4187,12 +4187,7 @@ export class LGraphNode
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/2652
|
||||
// TODO: Move the layout logic before drawing of the node shape, so we don't
|
||||
// need to trigger extra round of rendering.
|
||||
// In Vue mode, the DOM is the source of truth for node sizing — the
|
||||
// ResizeObserver feeds measurements back to the layout store. Allowing
|
||||
// LiteGraph to also call setSize() here creates an infinite feedback loop
|
||||
// (LG grows node → CSS min-height increases → textarea fills extra space →
|
||||
// ResizeObserver reports larger size → LG grows node again).
|
||||
if (!LiteGraph.vueNodesMode && y > bodyHeight) {
|
||||
if (y > bodyHeight) {
|
||||
this.setSize([this.size[0], y])
|
||||
this.graph.setDirtyCanvas(false, true)
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ export type IWidget =
|
||||
| IAssetWidget
|
||||
| IImageCropWidget
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -329,13 +328,6 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
|
||||
value: Bounds
|
||||
}
|
||||
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
|
||||
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
type: 'curve'
|
||||
value: CurvePoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ICurveWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class CurveWidget
|
||||
extends BaseWidget<ICurveWidget>
|
||||
implements ICurveWidget
|
||||
{
|
||||
override type = 'curve' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Curve')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { ButtonWidget } from './ButtonWidget'
|
||||
import { ChartWidget } from './ChartWidget'
|
||||
import { ColorWidget } from './ColorWidget'
|
||||
import { ComboWidget } from './ComboWidget'
|
||||
import { CurveWidget } from './CurveWidget'
|
||||
import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
@@ -57,7 +56,6 @@ export type WidgetTypeMap = {
|
||||
asset: AssetWidget
|
||||
imagecrop: ImageCropWidget
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -134,8 +132,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(ImageCropWidget, narrowedWidget, node)
|
||||
case 'boundingbox':
|
||||
return toClass(BoundingBoxWidget, narrowedWidget, node)
|
||||
case 'curve':
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "إنشاء حساب"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "فشل تحديث الاشتراك",
|
||||
"subscriptionProcessing": "جارٍ معالجة الدفع — يتم إعداد مساحة العمل...",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "حذف المخطط",
|
||||
"deleteWorkflow": "حذف سير العمل",
|
||||
"duplicate": "تكرار",
|
||||
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||
"enterNewName": "أدخل اسمًا جديدًا",
|
||||
"exitAppMode": "الخروج من وضع التطبيق",
|
||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
|
||||
"workflowActions": "إجراءات سير العمل"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "تطبيق",
|
||||
"appDescription": "يفتح كتطبيق بشكل افتراضي",
|
||||
"arrange": "معاينة",
|
||||
"arrangeDescription": "مراجعة تخطيط التطبيق",
|
||||
"connectOutput": "توصيل مخرج",
|
||||
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
|
||||
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"filename": "اسم الملف",
|
||||
"label": "منشئ التطبيقات",
|
||||
"nodeGraph": "رسم العقد",
|
||||
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
|
||||
"save": "حفظ",
|
||||
"saveAs": "حفظ باسم",
|
||||
"saveAsLabel": "احفظ سير العمل هذا كـ ...",
|
||||
"saveDescription": "حفظ وإنهاء",
|
||||
"saveSuccess": "تم الحفظ بنجاح",
|
||||
"saveSuccessAppMessage": "تم حفظ '{name}'. سيفتح في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
|
||||
"saveSuccessAppPrompt": "هل ترغب في عرضه الآن؟",
|
||||
"saveSuccessGraphMessage": "تم حفظ '{name}'. سيفتح كرسم عقد بشكل افتراضي.",
|
||||
"select": "تحديد",
|
||||
"selectDescription": "اختيار المدخلات/المخرجات",
|
||||
"switchToSelect": "الانتقال إلى التحديد",
|
||||
"viewApp": "عرض التطبيق"
|
||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر)."
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "فشل النسخ إلى الحافظة",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "تفعيل المحدد",
|
||||
"enabled": "ممكّن",
|
||||
"enabling": "جارٍ التمكين",
|
||||
"enter": "إدخال",
|
||||
"enterBaseName": "أدخل الاسم الأساسي",
|
||||
"enterNewName": "أدخل الاسم الجديد",
|
||||
"enterNewNamePrompt": "أدخل اسمًا جديدًا:",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "نجمة"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "أ: ",
|
||||
"batchLabelB": "ب: ",
|
||||
"noImages": "لا توجد صور للمقارنة"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "المساعدة في الإصلاح"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "منشئ التطبيقات",
|
||||
"apps": "التطبيقات"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "عقدة واحدة على الأقل",
|
||||
"connectAtLeastOne": "قم بتوصيل {atLeastOne} عقدة مخرجات حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
|
||||
"noOutputs": "لم تتم إضافة أي مخرجات بعد",
|
||||
"outputExamples": "أمثلة: 'حفظ صورة' أو 'حفظ فيديو'",
|
||||
"outputs": "المخرجات",
|
||||
"resultsLabel": "سيتم عرض النتائج الناتجة من عقدة/عقد المخرجات المحددة هنا بعد تشغيل هذا التطبيق",
|
||||
"switchToSelect": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"switchToSelectButton": "الانتقال إلى التحديد"
|
||||
},
|
||||
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "إعادة استخدام المعلمات",
|
||||
"runCount": "عدد مرات التشغيل:",
|
||||
"welcome": {
|
||||
"backToWorkflow": "العودة إلى سير العمل",
|
||||
"buildApp": "إنشاء تطبيق",
|
||||
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
|
||||
"getStarted": "انقر على {runButton} للبدء.",
|
||||
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
|
||||
"intro": "عرض مبسط يخفي مخطط العقد حتى تتمكن من التركيز على الإبداع.",
|
||||
"layout": "على اليسار، سترى الصور والفيديوهات والمخرجات التي تم إنشاؤها. على اليمين، فقط عناصر التحكم التي تحتاجها. كل ما هو معقد يبقى بعيدًا عن الأنظار.",
|
||||
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
|
||||
"title": "مرحبًا بك في وضع التطبيق"
|
||||
"title": "مرحبًا بك في وضع التطبيق",
|
||||
"widget": "إذا كنت تريد التحكم في الإعدادات الظاهرة، حوّل العقد العليا إلى مخطط فرعي، ثم استخدم ترقية عناصر التحكم في الأدوات أعلاه لاختيار ما يتم عرضه."
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "بكل سرور، سأساعد!",
|
||||
"description": "لقد استخدمت هذه الميزة. هل يمكنك تخصيص لحظة لمشاركة ملاحظاتك؟",
|
||||
"dontAskAgain": "لا تسأل مرة أخرى",
|
||||
"loadError": "فشل في تحميل الاستبيان. يرجى المحاولة لاحقًا.",
|
||||
"notNow": "ليس الآن",
|
||||
"title": "ساعدنا في التحسين"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "ثلاثي الأبعاد",
|
||||
@@ -2657,17 +2599,14 @@
|
||||
"cannotDeleteGlobal": "لا يمكن حذف المخططات المثبتة",
|
||||
"confirmDelete": "سيؤدي هذا الإجراء إلى إزالة المخطط نهائيًا من مكتبتك",
|
||||
"confirmDeleteTitle": "حذف المخطط؟",
|
||||
"disconnected": "غير متصل",
|
||||
"enterDescription": "أدخل وصفًا",
|
||||
"enterSearchAliases": "أدخل الأسماء المستعارة للبحث (مفصولة بفواصل)",
|
||||
"hidden": "معاملات مخفية / متداخلة",
|
||||
"hideAll": "إخفاء الكل",
|
||||
"linked": "(مرتبط)",
|
||||
"loadFailure": "فشل تحميل مخططات الرسم البياني الفرعي",
|
||||
"overwriteBlueprint": "سيؤدي الحفظ إلى استبدال المخطط الحالي بالتغييرات الخاصة بك",
|
||||
"overwriteBlueprintTitle": "استبدال المخطط الحالي؟",
|
||||
"promoteOutsideSubgraph": "لا يمكن ترقية عنصر واجهة المستخدم عند عدم وجوده في الرسم البياني الفرعي",
|
||||
"promoteWidget": "ترقية الأداة: {name}",
|
||||
"publish": "نشر الرسم البياني الفرعي",
|
||||
"publishSuccess": "تم الحفظ في مكتبة العقد",
|
||||
"publishSuccessMessage": "يمكنك العثور على مخطط الرسم البياني الفرعي الخاص بك في مكتبة العقد ضمن \"مخططات الرسم البياني الفرعي\"",
|
||||
@@ -2675,8 +2614,7 @@
|
||||
"searchAliases": "بحث عن الأسماء المستعارة",
|
||||
"showAll": "إظهار الكل",
|
||||
"showRecommended": "إظهار العناصر الموصى بها",
|
||||
"shown": "معروض على العقدة",
|
||||
"unpromoteWidget": "إلغاء ترقية الأداة: {name}"
|
||||
"shown": "معروض على العقدة"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "إضافة رصيد API",
|
||||
@@ -2914,7 +2852,6 @@
|
||||
"emptyCanvas": "لوحة فارغة",
|
||||
"errorCopyImage": "خطأ في نسخ الصورة: {error}",
|
||||
"errorLoadingModel": "خطأ في تحميل النموذج",
|
||||
"errorOpenImage": "حدث خطأ أثناء فتح الصورة: {error}",
|
||||
"errorSaveSetting": "خطأ في حفظ الإعداد {id}: {err}",
|
||||
"exportSuccess": "تم تصدير النموذج بنجاح كـ {format}",
|
||||
"failedExecutionPathResolution": "تعذر حل المسار إلى العُقَد المحددة",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"tooltip": "الحد الأقصى لعدد الصور التي سيتم توليدها عندما يكون sequential_image_generation='auto'. إجمالي الصور (المدخلة + المولدة) لا يمكن أن يتجاوز 15."
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
"name": "model",
|
||||
"tooltip": "اسم النموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "دمج قائمة القطع إلى صورة",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "محمل نقطة تحقق الصور فقط (نموذج img2vid)",
|
||||
"inputs": {
|
||||
@@ -5348,39 +5327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "إنشاء مقاطع فيديو رقمية بأسلوب البث المباشر لإنسان رقمي من صورة واحدة وملف صوتي.",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة",
|
||||
"tooltip": "صورة مرجعية للأفاتار. يجب ألا يقل العرض والارتفاع عن ٣٠٠ بكسل. يجب أن تكون نسبة الأبعاد بين ١:٢.٥ و٢.٥:١."
|
||||
},
|
||||
"mode": {
|
||||
"name": "الوضع"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "موجه",
|
||||
"tooltip": "موجه اختياري لتحديد حركات الأفاتار، المشاعر، وحركات الكاميرا."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "ملف صوتي",
|
||||
"tooltip": "إدخال صوتي. يجب أن تتراوح المدة بين ٢ و٣٠٠ ثانية."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "تحويل الصور الثابتة إلى فيديوهات سينمائية مع حركات كاميرا احترافية تحاكي التصوير السينمائي الحقيقي. تحكم في زووم، دوران، تحريك الكاميرا، الميل، والرؤية من منظور الشخص الأول مع الحفاظ على تركيز الصورة الأصلية.",
|
||||
"display_name": "تحكم كاميرا كليغ: صورة إلى فيديو",
|
||||
@@ -14056,29 +14002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "يقوم بتقسيم صورة إلى قائمة دفعات من القطع مع تداخل محدد.",
|
||||
"display_name": "تجزئة الصورة إلى قائمة قطع",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "فصل الصورة مع ألفا",
|
||||
"inputs": {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"empty": "Empty",
|
||||
"noWorkflowsFound": "No workflows found.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"inSubgraph": "in subgraph {name}",
|
||||
"download": "Download",
|
||||
"downloadImage": "Download image",
|
||||
"downloadVideo": "Download video",
|
||||
@@ -72,6 +71,7 @@
|
||||
"error": "Error",
|
||||
"enter": "Enter",
|
||||
"enterSubgraph": "Enter Subgraph",
|
||||
"inSubgraph": "in subgraph '{name}'",
|
||||
"resizeFromBottomRight": "Resize from bottom-right corner",
|
||||
"resizeFromTopRight": "Resize from top-right corner",
|
||||
"resizeFromBottomLeft": "Resize from bottom-left corner",
|
||||
@@ -450,6 +450,9 @@
|
||||
"import_failed": "Import Failed"
|
||||
},
|
||||
"warningTooltip": "This package may have compatibility issues with your current environment"
|
||||
},
|
||||
"packInstall": {
|
||||
"nodeIdRequired": "Node ID is required for installation"
|
||||
}
|
||||
},
|
||||
"importFailed": {
|
||||
@@ -1750,15 +1753,8 @@
|
||||
"doNotAskAgain": "Don't show this again",
|
||||
"reEnableInSettings": "Re-enable in {link}",
|
||||
"reEnableInSettingsLink": "Settings",
|
||||
"title": "This workflow is missing models",
|
||||
"description": "This workflow requires models you haven't downloaded yet.",
|
||||
"totalSize": "Total download size:",
|
||||
"downloadAll": "Download all",
|
||||
"downloadAvailable": "Download available",
|
||||
"gotIt": "Ok, got it",
|
||||
"footerDescription": "Download and place these models in the correct folder.\nNodes with missing models are highlighted red on the canvas.",
|
||||
"customModelsWarning": "Some of these are custom models that we don't recognize.",
|
||||
"customModelsInstruction": "You'll need to find and download them manually. Search for them online (try Civitai or HuggingFace) or contact the original workflow provider."
|
||||
"missingModels": "Missing Models",
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
},
|
||||
"versionMismatchWarning": {
|
||||
"title": "Version Compatibility Warning",
|
||||
@@ -1918,7 +1914,6 @@
|
||||
"nodeDefinitionsUpdated": "Node definitions updated",
|
||||
"errorSaveSetting": "Error saving setting {id}: {err}",
|
||||
"errorCopyImage": "Error copying image: {error}",
|
||||
"errorOpenImage": "Error opening image: {error}",
|
||||
"noTemplatesToExport": "No templates to export",
|
||||
"failedToFetchLogs": "Failed to fetch server logs",
|
||||
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute.",
|
||||
@@ -1988,7 +1983,6 @@
|
||||
"newUser": "New here?",
|
||||
"userAvatar": "User Avatar",
|
||||
"signUp": "Sign up",
|
||||
"signUpFreeTierPromo": "New here? {signUp} with Google to get {credits} free credits every month.",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"passwordLabel": "Password",
|
||||
@@ -2013,12 +2007,7 @@
|
||||
"failed": "Login failed",
|
||||
"insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.",
|
||||
"questionsContactPrefix": "Questions? Contact us at",
|
||||
"noAssociatedUser": "There is no Comfy user associated with the provided API key",
|
||||
"useEmailInstead": "Use email instead",
|
||||
"freeTierBadge": "Eligible for Free Tier",
|
||||
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
|
||||
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
|
||||
"backToSocialLogin": "Sign up with Google or Github instead"
|
||||
"noAssociatedUser": "There is no Comfy user associated with the provided API key"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
@@ -2032,8 +2021,7 @@
|
||||
"signUpWithGoogle": "Sign up with Google",
|
||||
"signUpWithGithub": "Sign up with Github",
|
||||
"regionRestrictionChina": "In accordance with local regulatory requirements, our services are temporarily unavailable to users located in China.",
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data.",
|
||||
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
|
||||
"personalDataConsentLabel": "I agree to the processing of my personal data."
|
||||
},
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
@@ -2224,15 +2212,10 @@
|
||||
"invoiceHistory": "Invoice history",
|
||||
"benefits": {
|
||||
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit1FreeTier": "More monthly credits, top up anytime",
|
||||
"benefit2": "Up to 1 hour runtime per job on Pro",
|
||||
"benefit3": "Bring your own models (Creator & Pro)"
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Free"
|
||||
},
|
||||
"founder": {
|
||||
"name": "Founder's Edition"
|
||||
},
|
||||
@@ -2266,21 +2249,6 @@
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "View enterprise",
|
||||
"freeTier": {
|
||||
"title": "You're on the Free plan",
|
||||
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
|
||||
"descriptionGeneric": "Your free plan includes a monthly credit allowance to try Comfy Cloud.",
|
||||
"nextRefresh": "Your credits refresh on {date}.",
|
||||
"subscribeCta": "Subscribe for more",
|
||||
"outOfCredits": {
|
||||
"title": "You're out of free credits",
|
||||
"subtitle": "Subscribe to unlock top-ups and more"
|
||||
},
|
||||
"topUpBlocked": {
|
||||
"title": "Unlock top-ups and more"
|
||||
},
|
||||
"upgradeCta": "View plans"
|
||||
},
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"plansAndPricing": "Plans & pricing",
|
||||
"managePlan": "Manage plan",
|
||||
@@ -2308,7 +2276,6 @@
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"maxDuration": {
|
||||
"free": "30 min",
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr",
|
||||
@@ -3122,7 +3089,20 @@
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
"errorHelpSupport": "contact our support",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
"resetAllParameters": "Reset all parameters",
|
||||
"missingNodePacks": {
|
||||
"title": "Missing Node Packs",
|
||||
"unsupportedTitle": "Unsupported Node Packs",
|
||||
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
|
||||
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
|
||||
"installAll": "Install All",
|
||||
"installNodePack": "Install node pack",
|
||||
"unknownPack": "Unknown pack",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"applyChanges": "Apply Changes",
|
||||
"searchInManager": "Search in Node Manager"
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
||||
|
||||
@@ -673,11 +673,12 @@
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedreamNode": {
|
||||
"display_name": "ByteDance Seedream 5.0",
|
||||
"display_name": "ByteDance Seedream 4.5",
|
||||
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
"name": "model",
|
||||
"tooltip": "Model name"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -689,7 +690,7 @@
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Input image(s) for image-to-image generation. Reference image(s) for single or multi-reference generation."
|
||||
"tooltip": "Input image(s) for image-to-image generation. List of 1-10 images for single or multi-reference generation."
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "Merge List of Tiles to Image",
|
||||
"inputs": {
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "Image Only Checkpoint Loader (img2vid model)",
|
||||
"inputs": {
|
||||
@@ -5234,39 +5213,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"description": "Generate broadcast-style digital human videos from a single photo and an audio file.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Avatar reference image. Width and height must be at least 300px. Aspect ratio must be between 1:2.5 and 2.5:1."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "sound_file",
|
||||
"tooltip": "Audio input. Must be between 2 and 300 seconds in duration."
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Optional prompt to define avatar actions, emotions, and camera movements."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"display_name": "Kling Image to Video (Camera Control)",
|
||||
"description": "Transform still images into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original image.",
|
||||
@@ -13973,29 +13919,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"display_name": "Split Image into List of Tiles",
|
||||
"description": "Splits an image into a batched list of tiles with a specified overlap.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "Split Image with Alpha",
|
||||
"inputs": {
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "Crea una cuenta"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "Error al actualizar la suscripción",
|
||||
"subscriptionProcessing": "Procesando pago — configurando tu espacio de trabajo...",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "Eliminar Plano",
|
||||
"deleteWorkflow": "Eliminar flujo de trabajo",
|
||||
"duplicate": "Duplicar",
|
||||
"enterAppMode": "Entrar en modo aplicación",
|
||||
"enterNewName": "Ingrese un nuevo nombre",
|
||||
"exitAppMode": "Salir del modo aplicación",
|
||||
"missingNodesWarning": "El flujo de trabajo contiene nodos no compatibles (resaltados en rojo).",
|
||||
"workflowActions": "Acciones del flujo de trabajo"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "Aplicación",
|
||||
"appDescription": "Se abre como una aplicación por defecto",
|
||||
"arrange": "Vista previa",
|
||||
"arrangeDescription": "Revisar el diseño de la aplicación",
|
||||
"connectOutput": "Conectar una salida",
|
||||
"connectOutputBody1": "Tu aplicación necesita al menos una salida conectada antes de poder guardarse.",
|
||||
"connectOutputBody2": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
|
||||
"filename": "Nombre de archivo",
|
||||
"label": "Constructor de aplicaciones",
|
||||
"nodeGraph": "Grafo de nodos",
|
||||
"nodeGraphDescription": "Se abre como grafo de nodos por defecto",
|
||||
"save": "Guardar",
|
||||
"saveAs": "Guardar como",
|
||||
"saveAsLabel": "Guardar este flujo de trabajo como...",
|
||||
"saveDescription": "Guardar y finalizar",
|
||||
"saveSuccess": "Guardado exitosamente",
|
||||
"saveSuccessAppMessage": "'{name}' ha sido guardado. Se abrirá en modo aplicación por defecto de ahora en adelante.",
|
||||
"saveSuccessAppPrompt": "¿Te gustaría verlo ahora?",
|
||||
"saveSuccessGraphMessage": "'{name}' ha sido guardado. Se abrirá como grafo de nodos por defecto.",
|
||||
"select": "Seleccionar",
|
||||
"selectDescription": "Elegir entradas/salidas",
|
||||
"switchToSelect": "Cambiar a Seleccionar",
|
||||
"viewApp": "Ver aplicación"
|
||||
"missingNodesWarning": "El flujo de trabajo contiene nodos no compatibles (resaltados en rojo)."
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Error al copiar al portapapeles",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "Habilitar seleccionados",
|
||||
"enabled": "Habilitado",
|
||||
"enabling": "Habilitando",
|
||||
"enter": "Entrar",
|
||||
"enterBaseName": "Introduce el nombre base",
|
||||
"enterNewName": "Introduce el nuevo nombre",
|
||||
"enterNewNamePrompt": "Introduce un nuevo nombre:",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "Estrella"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:",
|
||||
"noImages": "No hay imágenes para comparar"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "Ayuda a Solucionar Esto"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Constructor de aplicaciones",
|
||||
"apps": "Aplicaciones"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "al menos uno",
|
||||
"connectAtLeastOne": "Conecta {atLeastOne} nodo de salida para que los usuarios puedan ver los resultados después de ejecutar.",
|
||||
"noOutputs": "Aún no se han añadido salidas",
|
||||
"outputExamples": "Ejemplos: 'Guardar imagen' o 'Guardar video'",
|
||||
"outputs": "Salidas",
|
||||
"resultsLabel": "Los resultados generados por el/los nodo(s) de salida seleccionados se mostrarán aquí después de ejecutar esta aplicación",
|
||||
"switchToSelect": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
|
||||
"switchToSelectButton": "Cambiar a Seleccionar"
|
||||
},
|
||||
"beta": "Modo App Beta - Enviar comentarios",
|
||||
"downloadAll": "Descargar todo",
|
||||
"dragAndDropImage": "Arrastra y suelta una imagen",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "Reutilizar parámetros",
|
||||
"runCount": "Número de ejecuciones:",
|
||||
"welcome": {
|
||||
"backToWorkflow": "Volver al flujo de trabajo",
|
||||
"buildApp": "Crear aplicación",
|
||||
"controls": "Tus resultados aparecen abajo, tus controles están a la derecha. Todo lo demás se mantiene fuera del camino.",
|
||||
"getStarted": "Haz clic en {runButton} para comenzar.",
|
||||
"message": "Una vista simplificada que oculta el grafo de nodos para que puedas concentrarte en crear.",
|
||||
"intro": "Una vista simplificada que oculta el grafo de nodos para que puedas concentrarte en crear.",
|
||||
"layout": "A la izquierda, verás tus imágenes, videos y resultados generados. A la derecha, solo los controles necesarios. Todo lo complejo queda fuera de la vista.",
|
||||
"sharing": "Compartir es fácil: crea tu flujo de trabajo, abre el Modo App, haz clic derecho en la pestaña y exporta. Cuando otros abran tu archivo, se lanzará directamente en esta vista limpia. Puedes compartir flujos de trabajo potentes como herramientas simples sin que nadie tenga que entender grafos de nodos.",
|
||||
"title": "Bienvenido al Modo App"
|
||||
"title": "Bienvenido al Modo App",
|
||||
"widget": "Si quieres controlar qué ajustes aparecen, convierte tus nodos principales en un subgrafo y luego usa la promoción de widgets en la barra de herramientas sobre él para elegir qué se expone."
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "Estás usando una versión nightly de ComfyUI. Por favor, utiliza el botón de comentarios para compartir tus opiniones sobre estas funciones."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "¡Claro, ayudaré!",
|
||||
"description": "Has estado usando esta función. ¿Te tomarías un momento para compartir tus comentarios?",
|
||||
"dontAskAgain": "No preguntar de nuevo",
|
||||
"loadError": "No se pudo cargar la encuesta. Por favor, inténtalo de nuevo más tarde.",
|
||||
"notNow": "Ahora no",
|
||||
"title": "Ayúdanos a mejorar"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "3d",
|
||||
@@ -2657,17 +2599,14 @@
|
||||
"cannotDeleteGlobal": "No se pueden eliminar los blueprints instalados",
|
||||
"confirmDelete": "Esta acción eliminará permanentemente el subgrafo de tu biblioteca",
|
||||
"confirmDeleteTitle": "¿Eliminar subgrafo?",
|
||||
"disconnected": "Desconectado",
|
||||
"enterDescription": "Introduce una descripción",
|
||||
"enterSearchAliases": "Introduce alias de búsqueda (separados por comas)",
|
||||
"hidden": "Parámetros ocultos/anidados",
|
||||
"hideAll": "Ocultar todo",
|
||||
"linked": "(Vinculado)",
|
||||
"loadFailure": "No se pudieron cargar los subgrafos",
|
||||
"overwriteBlueprint": "Guardar sobrescribirá el subgrafo actual con tus cambios",
|
||||
"overwriteBlueprintTitle": "¿Sobrescribir subgrafo existente?",
|
||||
"promoteOutsideSubgraph": "No se puede promocionar widget cuando no está en subgrafo",
|
||||
"promoteWidget": "Promocionar widget: {name}",
|
||||
"publish": "Publicar subgrafo",
|
||||
"publishSuccess": "Guardado en la biblioteca de nodos",
|
||||
"publishSuccessMessage": "Puedes encontrar tu subgrafo en la biblioteca de nodos bajo \"Subgraph Blueprints\"",
|
||||
@@ -2675,8 +2614,7 @@
|
||||
"searchAliases": "Buscar alias",
|
||||
"showAll": "Mostrar todo",
|
||||
"showRecommended": "Mostrar widgets recomendados",
|
||||
"shown": "Mostrado en el nodo",
|
||||
"unpromoteWidget": "Dejar de promocionar widget: {name}"
|
||||
"shown": "Mostrado en el nodo"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "Agregar créditos de API",
|
||||
@@ -2914,7 +2852,6 @@
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
"errorCopyImage": "Error al copiar la imagen: {error}",
|
||||
"errorLoadingModel": "Error al cargar el modelo",
|
||||
"errorOpenImage": "Error al abrir la imagen: {error}",
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"exportSuccess": "Modelo exportado exitosamente como {format}",
|
||||
"failedExecutionPathResolution": "No se pudo resolver la ruta a los nodos seleccionados",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"tooltip": "Número máximo de imágenes a generar cuando generación_secuencial_de_imágenes='automático'. El total de imágenes (entrada + generadas) no puede exceder 15."
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo"
|
||||
"name": "modelo",
|
||||
"tooltip": "Nombre del modelo"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "Unir lista de mosaicos en imagen",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "Cargador de Puntos de Control Solo de Imagen (modelo img2vid)",
|
||||
"inputs": {
|
||||
@@ -5348,39 +5327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "Genera videos de humanos digitales de estilo broadcast a partir de una sola foto y un archivo de audio.",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen",
|
||||
"tooltip": "Imagen de referencia del avatar. El ancho y la altura deben ser de al menos 300 px. La relación de aspecto debe estar entre 1:2.5 y 2.5:1."
|
||||
},
|
||||
"mode": {
|
||||
"name": "modo"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt opcional para definir acciones, emociones y movimientos de cámara del avatar."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "archivo_de_audio",
|
||||
"tooltip": "Entrada de audio. Debe tener una duración entre 2 y 300 segundos."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "Transforma imágenes fijas en videos cinematográficos con movimientos de cámara profesionales que simulan la cinematografía del mundo real. Controla acciones de cámara virtual como zoom, rotación, paneo, inclinación y vista en primera persona, manteniendo el enfoque en tu imagen original.",
|
||||
"display_name": "Kling Imagen a Video (Control de Cámara)",
|
||||
@@ -14056,29 +14002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "Divide una imagen en una lista agrupada de mosaicos con una superposición especificada.",
|
||||
"display_name": "Dividir imagen en lista de mosaicos",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "Dividir Imagen con Alfa",
|
||||
"inputs": {
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "ایجاد حساب کاربری"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "بهروزرسانی اشتراک ناموفق بود",
|
||||
"subscriptionProcessing": "در حال پردازش پرداخت — در حال راهاندازی فضای کاری...",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "حذف blueprint",
|
||||
"deleteWorkflow": "حذف workflow",
|
||||
"duplicate": "تکرار",
|
||||
"enterAppMode": "ورود به حالت اپلیکیشن",
|
||||
"enterNewName": "نام جدید را وارد کنید",
|
||||
"exitAppMode": "خروج از حالت اپلیکیشن",
|
||||
"missingNodesWarning": "workflow شامل نودهای پشتیبانینشده است (با رنگ قرمز مشخص شدهاند).",
|
||||
"workflowActions": "عملیات گردشکار"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "اپلیکیشن",
|
||||
"appDescription": "به طور پیشفرض به صورت اپلیکیشن باز میشود",
|
||||
"arrange": "پیشنمایش",
|
||||
"arrangeDescription": "بررسی چیدمان اپلیکیشن",
|
||||
"connectOutput": "اتصال خروجی",
|
||||
"connectOutputBody1": "اپلیکیشن شما باید حداقل یک خروجی متصل داشته باشد تا بتوان آن را ذخیره کرد.",
|
||||
"connectOutputBody2": "به مرحله «انتخاب» بروید و روی nodeهای خروجی کلیک کنید تا اینجا اضافه شوند.",
|
||||
"filename": "نام فایل",
|
||||
"label": "سازنده اپلیکیشن",
|
||||
"nodeGraph": "گراف node",
|
||||
"nodeGraphDescription": "به طور پیشفرض به صورت گراف node باز میشود",
|
||||
"save": "ذخیره",
|
||||
"saveAs": "ذخیره به عنوان",
|
||||
"saveAsLabel": "این گردشکار را ذخیره کنید به عنوان ...",
|
||||
"saveDescription": "ذخیره و پایان",
|
||||
"saveSuccess": "با موفقیت ذخیره شد",
|
||||
"saveSuccessAppMessage": "'{name}' ذخیره شد. از این پس به طور پیشفرض در حالت اپلیکیشن باز خواهد شد.",
|
||||
"saveSuccessAppPrompt": "آیا مایلید اکنون آن را مشاهده کنید؟",
|
||||
"saveSuccessGraphMessage": "'{name}' ذخیره شد. به طور پیشفرض به صورت گراف node باز خواهد شد.",
|
||||
"select": "انتخاب",
|
||||
"selectDescription": "انتخاب ورودی/خروجیها",
|
||||
"switchToSelect": "رفتن به انتخاب",
|
||||
"viewApp": "مشاهده اپلیکیشن"
|
||||
"missingNodesWarning": "workflow شامل نودهای پشتیبانینشده است (با رنگ قرمز مشخص شدهاند)."
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "کپی به کلیپبورد ناموفق بود",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "فعالسازی انتخابشدهها",
|
||||
"enabled": "فعال",
|
||||
"enabling": "در حال فعالسازی {id}",
|
||||
"enter": "ورود",
|
||||
"enterBaseName": "نام پایه را وارد کنید",
|
||||
"enterNewName": "نام جدید را وارد کنید",
|
||||
"enterNewNamePrompt": "نام جدید را وارد کنید:",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "ستاره"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:",
|
||||
"noImages": "تصویری برای مقایسه وجود ندارد"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "کمک به رفع این مشکل"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "سازنده اپلیکیشن",
|
||||
"apps": "اپلیکیشنها"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "یک",
|
||||
"connectAtLeastOne": "حداقل {atLeastOne} node خروجی را متصل کنید تا کاربران پس از اجرا نتایج را مشاهده کنند.",
|
||||
"noOutputs": "هنوز هیچ خروجی اضافه نشده است",
|
||||
"outputExamples": "مثالها: «ذخیره تصویر» یا «ذخیره ویدیو»",
|
||||
"outputs": "خروجیها",
|
||||
"resultsLabel": "نتایج تولیدشده از node(های) خروجی انتخابشده پس از اجرای این اپلیکیشن اینجا نمایش داده میشوند.",
|
||||
"switchToSelect": "به مرحله «انتخاب» بروید و روی nodeهای خروجی کلیک کنید تا اینجا اضافه شوند.",
|
||||
"switchToSelectButton": "رفتن به انتخاب"
|
||||
},
|
||||
"beta": "حالت برنامه بتا - ارسال بازخورد",
|
||||
"downloadAll": "دانلود همه",
|
||||
"dragAndDropImage": "تصویر را بکشید و رها کنید",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "استفاده مجدد از پارامترها",
|
||||
"runCount": "تعداد اجرا: ",
|
||||
"welcome": {
|
||||
"backToWorkflow": "بازگشت به گردشکار",
|
||||
"buildApp": "ساخت اپلیکیشن",
|
||||
"controls": "خروجیهای شما در پایین نمایش داده میشوند و کنترلها در سمت راست قرار دارند. سایر موارد خارج از دید باقی میمانند.",
|
||||
"getStarted": "برای شروع روی {runButton} کلیک کنید.",
|
||||
"message": "نمای سادهشدهای که گراف node را مخفی میکند تا بتوانید بر خلق تمرکز کنید.",
|
||||
"intro": "نمایی سادهشده که گراف node را پنهان میکند تا بتوانید بر خلق تمرکز کنید.",
|
||||
"layout": "در سمت چپ، تصاویر، ویدیوها و خروجیهای تولیدشده خود را میبینید. در سمت راست، فقط کنترلهای مورد نیاز شما قرار دارند. همه چیزهای پیچیده از دید پنهان میمانند.",
|
||||
"sharing": "اشتراکگذاری آسان است: workflow خود را بسازید، حالت App را باز کنید، روی تب راستکلیک کنید و خروجی بگیرید. وقتی دیگران فایل شما را باز میکنند، مستقیماً وارد این نمای ساده میشوند. میتوانید workflowهای قدرتمند را به ابزارهای ساده تبدیل و به اشتراک بگذارید بدون اینکه کسی نیاز به درک گراف node داشته باشد.",
|
||||
"title": "به حالت App خوش آمدید"
|
||||
"title": "به حالت App خوش آمدید",
|
||||
"widget": "اگر میخواهید کنترل کنید کدام تنظیمات نمایش داده شوند، nodeهای سطح بالای خود را به یک subgraph تبدیل کنید، سپس با استفاده از ابزارک promotion در جعبهابزار بالای آن، موارد قابل نمایش را انتخاب کنید."
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "شما در حال استفاده از نسخه شبانه ComfyUI هستید. لطفاً با استفاده از دکمه بازخورد، نظرات خود را درباره این قابلیتها به اشتراک بگذارید."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "بله، کمک میکنم!",
|
||||
"description": "شما از این قابلیت استفاده کردهاید. آیا مایل هستید بازخورد خود را با ما به اشتراک بگذارید؟",
|
||||
"dontAskAgain": "دیگر نمایش نده",
|
||||
"loadError": "بارگذاری نظرسنجی ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
||||
"notNow": "فعلاً نه",
|
||||
"title": "به ما در بهبود کمک کنید"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "سهبعدی",
|
||||
@@ -2669,17 +2611,14 @@
|
||||
"cannotDeleteGlobal": "امکان حذف blueprints نصبشده وجود ندارد",
|
||||
"confirmDelete": "این عمل باعث حذف دائمی بلوپرینت از کتابخانه شما میشود",
|
||||
"confirmDeleteTitle": "حذف بلوپرینت؟",
|
||||
"disconnected": "قطع ارتباط",
|
||||
"enterDescription": "توضیحی وارد کنید",
|
||||
"enterSearchAliases": "نامهای مستعار جستجو را وارد کنید (با ویرگول جدا کنید)",
|
||||
"hidden": "پارامترهای مخفی / تو در تو",
|
||||
"hideAll": "مخفیسازی همه",
|
||||
"linked": "(متصل)",
|
||||
"loadFailure": "بارگذاری بلوپرینتهای زیرگراف ناموفق بود",
|
||||
"overwriteBlueprint": "ذخیره باعث جایگزینی بلوپرینت فعلی با تغییرات شما میشود",
|
||||
"overwriteBlueprintTitle": "جایگزینی بلوپرینت موجود؟",
|
||||
"promoteOutsideSubgraph": "امکان ارتقاء ویجت خارج از زیرگراف وجود ندارد",
|
||||
"promoteWidget": "ارتقاء ویجت: {name}",
|
||||
"publish": "انتشار زیرگراف",
|
||||
"publishSuccess": "در کتابخانه گرهها ذخیره شد",
|
||||
"publishSuccessMessage": "میتوانید بلوپرینت زیرگراف خود را در کتابخانه گرهها در بخش \"بلوپرینتهای زیرگراف\" پیدا کنید",
|
||||
@@ -2687,8 +2626,7 @@
|
||||
"searchAliases": "جستجوی نامهای مستعار",
|
||||
"showAll": "نمایش همه",
|
||||
"showRecommended": "نمایش ویجتهای پیشنهادی",
|
||||
"shown": "نمایش روی گره",
|
||||
"unpromoteWidget": "لغو ارتقاء ویجت: {name}"
|
||||
"shown": "نمایش روی گره"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "افزودن اعتبار API",
|
||||
@@ -2926,7 +2864,6 @@
|
||||
"emptyCanvas": "بوم خالی است",
|
||||
"errorCopyImage": "خطا در کپی تصویر: {error}",
|
||||
"errorLoadingModel": "خطا در بارگذاری مدل",
|
||||
"errorOpenImage": "خطا در باز کردن تصویر: {error}",
|
||||
"errorSaveSetting": "خطا در ذخیره تنظیمات {id}: {err}",
|
||||
"exportSuccess": "مدل با موفقیت با فرمت {format} صادر شد",
|
||||
"failedExecutionPathResolution": "مسیر اجرای nodeهای انتخابشده قابل شناسایی نیست",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"tooltip": "حداکثر تعداد تصاویری که هنگام فعال بودن تولید ترتیبی تصویر (auto) تولید میشود. مجموع تصاویر (ورودی + تولید شده) نباید از ۱۵ بیشتر باشد."
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل"
|
||||
"name": "مدل",
|
||||
"tooltip": "نام مدل"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "ادغام فهرست کاشیها به تصویر",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "بارگذاری Checkpoint فقط تصویر (مدل img2vid)",
|
||||
"inputs": {
|
||||
@@ -5348,39 +5327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "تولید ویدئوهای دیجیتال انسان به سبک پخش زنده از یک عکس و یک فایل صوتی.",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "تصویر مرجع آواتار. عرض و ارتفاع باید حداقل ۳۰۰ پیکسل باشد. نسبت ابعاد باید بین ۱:۲.۵ و ۲.۵:۱ باشد."
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "پرومپت اختیاری برای تعریف حرکات، احساسات و حرکات دوربین آواتار."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed تعیین میکند که آیا node باید دوباره اجرا شود؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "sound_file",
|
||||
"tooltip": "ورودی صوتی. مدت زمان باید بین ۲ تا ۳۰۰ ثانیه باشد."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "تبدیل تصاویر ثابت به ویدیوهای سینمایی با حرکات حرفهای دوربین که شبیهساز فیلمبرداری واقعی است. کنترل حرکات مجازی دوربین شامل زوم، چرخش، پن، تیلت و نمای اول شخص، در حالی که تمرکز بر تصویر اصلی شما حفظ میشود.",
|
||||
"display_name": "تبدیل تصویر به ویدیو Kling (کنترل دوربین)",
|
||||
@@ -14056,29 +14002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "یک تصویر را به فهرستی دستهای از کاشیها با همپوشانی مشخص تقسیم میکند.",
|
||||
"display_name": "تقسیم تصویر به فهرست کاشیها",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "تقسیم تصویر با آلفا",
|
||||
"inputs": {
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "Créer un compte"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "Échec de la mise à jour de l'abonnement",
|
||||
"subscriptionProcessing": "Traitement du paiement — configuration de votre espace de travail...",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "Supprimer le plan",
|
||||
"deleteWorkflow": "Supprimer le workflow",
|
||||
"duplicate": "Dupliquer",
|
||||
"enterAppMode": "Entrer en mode application",
|
||||
"enterNewName": "Entrez un nouveau nom",
|
||||
"exitAppMode": "Quitter le mode application",
|
||||
"missingNodesWarning": "Le workflow contient des nœuds non pris en charge (surlignés en rouge).",
|
||||
"workflowActions": "Actions du workflow"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "Application",
|
||||
"appDescription": "S'ouvre par défaut en tant qu'application",
|
||||
"arrange": "Aperçu",
|
||||
"arrangeDescription": "Vérifier la disposition de l'application",
|
||||
"connectOutput": "Connecter une sortie",
|
||||
"connectOutputBody1": "Votre application doit avoir au moins une sortie connectée avant de pouvoir être enregistrée.",
|
||||
"connectOutputBody2": "Passez à l'étape « Sélectionner » et cliquez sur les nœuds de sortie pour les ajouter ici.",
|
||||
"filename": "Nom du fichier",
|
||||
"label": "Créateur d'applications",
|
||||
"nodeGraph": "Graphe de nœuds",
|
||||
"nodeGraphDescription": "S'ouvre par défaut en tant que graphe de nœuds",
|
||||
"save": "Enregistrer",
|
||||
"saveAs": "Enregistrer sous",
|
||||
"saveAsLabel": "Enregistrer ce workflow sous ...",
|
||||
"saveDescription": "Enregistrer et terminer",
|
||||
"saveSuccess": "Enregistré avec succès",
|
||||
"saveSuccessAppMessage": "« {name} » a été enregistré. Il s'ouvrira désormais par défaut en mode application.",
|
||||
"saveSuccessAppPrompt": "Voulez-vous le voir maintenant ?",
|
||||
"saveSuccessGraphMessage": "« {name} » a été enregistré. Il s'ouvrira par défaut en tant que graphe de nœuds.",
|
||||
"select": "Sélectionner",
|
||||
"selectDescription": "Choisir les entrées/sorties",
|
||||
"switchToSelect": "Passer à Sélectionner",
|
||||
"viewApp": "Voir l'application"
|
||||
"missingNodesWarning": "Le workflow contient des nœuds non pris en charge (surlignés en rouge)."
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Échec de la copie dans le presse-papiers",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "Activer la sélection",
|
||||
"enabled": "Activé",
|
||||
"enabling": "Activation",
|
||||
"enter": "Entrer",
|
||||
"enterBaseName": "Entrez le nom de base",
|
||||
"enterNewName": "Entrez le nouveau nom",
|
||||
"enterNewNamePrompt": "Entrez le nouveau nom :",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "Étoile"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A :",
|
||||
"batchLabelB": "B :",
|
||||
"noImages": "Aucune image à comparer"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "Aidez à résoudre cela"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Créateur d'applications",
|
||||
"apps": "Applications"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "au moins un",
|
||||
"connectAtLeastOne": "Connectez {atLeastOne} nœud de sortie pour que les utilisateurs puissent voir les résultats après l'exécution.",
|
||||
"noOutputs": "Aucune sortie ajoutée pour le moment",
|
||||
"outputExamples": "Exemples : « Enregistrer l'image » ou « Enregistrer la vidéo »",
|
||||
"outputs": "Sorties",
|
||||
"resultsLabel": "Les résultats générés à partir du ou des nœuds de sortie sélectionnés seront affichés ici après l'exécution de cette application",
|
||||
"switchToSelect": "Passez à l'étape « Sélectionner » et cliquez sur les nœuds de sortie pour les ajouter ici.",
|
||||
"switchToSelectButton": "Passer à Sélectionner"
|
||||
},
|
||||
"beta": "Mode App Bêta - Donnez votre avis",
|
||||
"downloadAll": "Tout télécharger",
|
||||
"dragAndDropImage": "Glissez-déposez une image",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "Réutiliser les paramètres",
|
||||
"runCount": "Nombre d’exécutions :",
|
||||
"welcome": {
|
||||
"backToWorkflow": "Retour au workflow",
|
||||
"buildApp": "Créer une application",
|
||||
"controls": "Vos sorties apparaissent en bas, vos contrôles sont à droite. Tout le reste reste à l'écart.",
|
||||
"getStarted": "Cliquez sur {runButton} pour commencer.",
|
||||
"message": "Une vue simplifiée qui masque le graphe de nœuds pour vous permettre de vous concentrer sur la création.",
|
||||
"intro": "Une vue simplifiée qui masque le graphe de nœuds pour vous permettre de vous concentrer sur la création.",
|
||||
"layout": "À gauche, vous verrez vos images, vidéos et sorties générées. À droite, seulement les contrôles nécessaires. Tout ce qui est complexe reste caché.",
|
||||
"sharing": "Le partage est facile : créez votre workflow, ouvrez le mode App, faites un clic droit sur l’onglet et exportez. Quand d’autres ouvrent votre fichier, il s’ouvre directement dans cette vue épurée. Vous pouvez partager des workflows puissants comme des outils simples, sans que personne n’ait besoin de comprendre les graphes de nœuds.",
|
||||
"title": "Bienvenue en mode App"
|
||||
"title": "Bienvenue en mode App",
|
||||
"widget": "Si vous souhaitez contrôler quels paramètres apparaissent, convertissez vos nœuds principaux en sous-graphe, puis utilisez la promotion de widget dans la boîte à outils au-dessus pour choisir ce qui est exposé."
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "Vous utilisez une version nightly de ComfyUI. Veuillez utiliser le bouton de retour pour partager vos impressions sur ces fonctionnalités."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "Oui, je veux aider !",
|
||||
"description": "Vous avez utilisé cette fonctionnalité. Pourriez-vous prendre un moment pour partager votre avis ?",
|
||||
"dontAskAgain": "Ne plus demander",
|
||||
"loadError": "Échec du chargement du sondage. Veuillez réessayer plus tard.",
|
||||
"notNow": "Pas maintenant",
|
||||
"title": "Aidez-nous à nous améliorer"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "3d",
|
||||
@@ -2657,17 +2599,14 @@
|
||||
"cannotDeleteGlobal": "Impossible de supprimer les blueprints installés",
|
||||
"confirmDelete": "Cette action supprimera définitivement le plan de votre bibliothèque",
|
||||
"confirmDeleteTitle": "Supprimer le plan ?",
|
||||
"disconnected": "Déconnecté",
|
||||
"enterDescription": "Saisissez une description",
|
||||
"enterSearchAliases": "Saisissez des alias de recherche (séparés par des virgules)",
|
||||
"hidden": "Paramètres cachés / imbriqués",
|
||||
"hideAll": "Tout masquer",
|
||||
"linked": "(Lié)",
|
||||
"loadFailure": "Échec du chargement des plans de sous-graphes",
|
||||
"overwriteBlueprint": "L'enregistrement écrasera le plan actuel avec vos modifications",
|
||||
"overwriteBlueprintTitle": "Écraser le plan existant ?",
|
||||
"promoteOutsideSubgraph": "Impossible de promouvoir le widget en dehors d'un sous-graphe",
|
||||
"promoteWidget": "Promouvoir le widget : {name}",
|
||||
"publish": "Publier le sous-graphe",
|
||||
"publishSuccess": "Enregistré dans la bibliothèque de nœuds",
|
||||
"publishSuccessMessage": "Vous pouvez trouver votre plan de sous-graphe dans la bibliothèque de nœuds sous \"Plans de sous-graphes\"",
|
||||
@@ -2675,8 +2614,7 @@
|
||||
"searchAliases": "Rechercher des alias",
|
||||
"showAll": "Tout afficher",
|
||||
"showRecommended": "Afficher les widgets recommandés",
|
||||
"shown": "Affiché sur le nœud",
|
||||
"unpromoteWidget": "Rétrograder le widget : {name}"
|
||||
"shown": "Affiché sur le nœud"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "Ajouter des crédits API",
|
||||
@@ -2914,7 +2852,6 @@
|
||||
"emptyCanvas": "Toile vide",
|
||||
"errorCopyImage": "Erreur lors de la copie de l'image: {error}",
|
||||
"errorLoadingModel": "Erreur lors du chargement du modèle",
|
||||
"errorOpenImage": "Erreur lors de l'ouverture de l'image : {error}",
|
||||
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
|
||||
"exportSuccess": "Modèle exporté avec succès au format {format}",
|
||||
"failedExecutionPathResolution": "Impossible de résoudre le chemin vers les nœuds sélectionnés",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"tooltip": "Nombre maximum d'images à générer lorsque sequential_image_generation='auto'. Le nombre total d'images (entrée + générées) ne peut pas dépasser 15."
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
"name": "model",
|
||||
"tooltip": "Nom du modèle"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "Fusionner une liste de tuiles en image",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "hauteur_finale"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "largeur_finale"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "liste_d'images"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "chevauchement"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "Chargeur de points de contrôle uniquement pour image (modèle img2vid)",
|
||||
"inputs": {
|
||||
@@ -5348,39 +5327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "Générez des vidéos de type diffusion de personnages numériques à partir d'une seule photo et d'un fichier audio.",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Image de référence de l'avatar. La largeur et la hauteur doivent être d'au moins 300 px. Le rapport d'aspect doit être compris entre 1:2,5 et 2,5:1."
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite optionnelle pour définir les actions, émotions et mouvements de caméra de l'avatar."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats restent non déterministes quel que soit le seed."
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "sound_file",
|
||||
"tooltip": "Entrée audio. Doit durer entre 2 et 300 secondes."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "Transformez des images fixes en vidéos cinématographiques avec des mouvements de caméra professionnels qui simulent la cinématographie réelle. Contrôlez les actions de la caméra virtuelle, y compris le zoom, la rotation, le panoramique, l'inclinaison et la vue à la première personne, tout en maintenant la mise au point sur votre image d'origine.",
|
||||
"display_name": "Kling Image to Video (Contrôle de la caméra)",
|
||||
@@ -14056,29 +14002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "Divise une image en une liste groupée de tuiles avec un chevauchement spécifié.",
|
||||
"display_name": "Diviser l'image en liste de tuiles",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "chevauchement"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "hauteur_tuile"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "largeur_tuile"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "Diviser l'image avec Alpha",
|
||||
"inputs": {
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "アカウントを作成する"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "サブスクリプションの更新に失敗しました",
|
||||
"subscriptionProcessing": "お支払い処理中 — ワークスペースを設定しています…",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "ブループリントを削除",
|
||||
"deleteWorkflow": "ワークフローを削除",
|
||||
"duplicate": "複製",
|
||||
"enterAppMode": "アプリモードに入る",
|
||||
"enterNewName": "新しい名前を入力",
|
||||
"exitAppMode": "アプリモードを終了",
|
||||
"missingNodesWarning": "ワークフローに未対応のノードが含まれています(赤でハイライト)。",
|
||||
"workflowActions": "ワークフロー操作"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "アプリ",
|
||||
"appDescription": "デフォルトでアプリとして開きます",
|
||||
"arrange": "プレビュー",
|
||||
"arrangeDescription": "アプリのレイアウトを確認",
|
||||
"connectOutput": "出力を接続",
|
||||
"connectOutputBody1": "アプリを保存するには、少なくとも1つの出力を接続する必要があります。",
|
||||
"connectOutputBody2": "「選択」ステップに切り替えて、出力ノードをクリックしてここに追加してください。",
|
||||
"filename": "ファイル名",
|
||||
"label": "アプリビルダー",
|
||||
"nodeGraph": "ノードグラフ",
|
||||
"nodeGraphDescription": "デフォルトでノードグラフとして開きます",
|
||||
"save": "保存",
|
||||
"saveAs": "名前を付けて保存",
|
||||
"saveAsLabel": "このワークフローを次の形式で保存...",
|
||||
"saveDescription": "保存して終了",
|
||||
"saveSuccess": "保存に成功しました",
|
||||
"saveSuccessAppMessage": "「{name}」が保存されました。今後はデフォルトでアプリモードで開きます。",
|
||||
"saveSuccessAppPrompt": "今すぐ表示しますか?",
|
||||
"saveSuccessGraphMessage": "「{name}」が保存されました。今後はデフォルトでノードグラフとして開きます。",
|
||||
"select": "選択",
|
||||
"selectDescription": "入力/出力を選択",
|
||||
"switchToSelect": "選択に切り替え",
|
||||
"viewApp": "アプリを表示"
|
||||
"missingNodesWarning": "ワークフローに未対応のノードが含まれています(赤でハイライト)。"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "クリップボードへのコピーに失敗しました",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "選択したものを有効化",
|
||||
"enabled": "有効",
|
||||
"enabling": "有効化",
|
||||
"enter": "入力",
|
||||
"enterBaseName": "ベース名を入力",
|
||||
"enterNewName": "新しい名前を入力",
|
||||
"enterNewNamePrompt": "新しい名前を入力してください:",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "星"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:",
|
||||
"noImages": "比較する画像がありません"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "これを修正するのを助ける"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "アプリビルダー",
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "少なくとも1つ",
|
||||
"connectAtLeastOne": "ユーザーが実行後に結果を確認できるよう、{atLeastOne} 出力ノードを接続してください。",
|
||||
"noOutputs": "まだ出力が追加されていません",
|
||||
"outputExamples": "例:「画像を保存」や「動画を保存」",
|
||||
"outputs": "出力",
|
||||
"resultsLabel": "選択した出力ノードから生成された結果は、このアプリ実行後にここに表示されます",
|
||||
"switchToSelect": "「選択」ステップに切り替えて、出力ノードをクリックしてここに追加してください。",
|
||||
"switchToSelectButton": "選択に切り替え"
|
||||
},
|
||||
"beta": "アプリモード ベータ版 - フィードバックを送る",
|
||||
"downloadAll": "すべてダウンロード",
|
||||
"dragAndDropImage": "画像をドラッグ&ドロップ",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "パラメータを再利用",
|
||||
"runCount": "実行回数:",
|
||||
"welcome": {
|
||||
"backToWorkflow": "ワークフローに戻る",
|
||||
"buildApp": "アプリを作成",
|
||||
"controls": "出力は下部に表示され、コントロールは右側にあります。他の要素は邪魔になりません。",
|
||||
"getStarted": "{runButton} をクリックして開始してください。",
|
||||
"message": "ノードグラフを非表示にして、作成に集中できるシンプルなビューです。",
|
||||
"intro": "ノードグラフを非表示にして、作成に集中できるシンプルなビューです。",
|
||||
"layout": "左側には生成した画像、動画、出力が表示されます。右側には必要なコントロールだけが表示されます。複雑なものはすべて見えません。",
|
||||
"sharing": "共有も簡単です:ワークフローを作成し、アプリモードを開き、タブを右クリックしてエクスポートします。他の人がファイルを開くと、このクリーンなビューで直接起動します。ノードグラフを理解しなくても、強力なワークフローをシンプルなツールとして共有できます。",
|
||||
"title": "アプリモードへようこそ"
|
||||
"title": "アプリモードへようこそ",
|
||||
"widget": "表示する設定を制御したい場合は、トップレベルのノードをサブグラフに変換し、その上のツールボックスでウィジェットプロモーションを使って公開する項目を選択してください。"
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "現在、ComfyUI のナイトリーバージョンを使用しています。これらの機能についてご意見があれば、フィードバックボタンからお知らせください。"
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "はい、協力します!",
|
||||
"description": "この機能をご利用いただきありがとうございます。ご意見をお聞かせいただけますか?",
|
||||
"dontAskAgain": "今後表示しない",
|
||||
"loadError": "アンケートの読み込みに失敗しました。後でもう一度お試しください。",
|
||||
"notNow": "今はしない",
|
||||
"title": "改善にご協力ください"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "3d",
|
||||
@@ -2657,17 +2599,14 @@
|
||||
"cannotDeleteGlobal": "インストール済みのブループリントは削除できません",
|
||||
"confirmDelete": "この操作により、ライブラリからサブグラフが完全に削除されます",
|
||||
"confirmDeleteTitle": "サブグラフを削除しますか?",
|
||||
"disconnected": "切断されました",
|
||||
"enterDescription": "説明を入力してください",
|
||||
"enterSearchAliases": "検索用エイリアスを入力(カンマ区切り)",
|
||||
"hidden": "非表示/ネストされたパラメータ",
|
||||
"hideAll": "すべて非表示",
|
||||
"linked": "(リンク済み)",
|
||||
"loadFailure": "サブグラフの読み込みに失敗しました",
|
||||
"overwriteBlueprint": "保存すると、現在のサブグラフが変更内容で上書きされます",
|
||||
"overwriteBlueprintTitle": "既存のサブグラフを上書きしますか?",
|
||||
"promoteOutsideSubgraph": "サブグラフ内でない場合、ウィジェットを昇格できません",
|
||||
"promoteWidget": "ウィジェットを昇格: {name}",
|
||||
"publish": "サブグラフを公開",
|
||||
"publishSuccess": "ノードライブラリに保存されました",
|
||||
"publishSuccessMessage": "サブグラフはノードライブラリの「サブグラフブループリント」で見つけることができます",
|
||||
@@ -2675,8 +2614,7 @@
|
||||
"searchAliases": "エイリアスを検索",
|
||||
"showAll": "すべて表示",
|
||||
"showRecommended": "おすすめウィジェットを表示",
|
||||
"shown": "ノード上で表示",
|
||||
"unpromoteWidget": "ウィジェットの昇格を解除: {name}"
|
||||
"shown": "ノード上で表示"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "APIクレジットを追加",
|
||||
@@ -2914,7 +2852,6 @@
|
||||
"emptyCanvas": "キャンバスが空です",
|
||||
"errorCopyImage": "画像のコピーにエラーが発生しました: {error}",
|
||||
"errorLoadingModel": "モデルの読み込みエラー",
|
||||
"errorOpenImage": "画像の読み込み中にエラーが発生しました: {error}",
|
||||
"errorSaveSetting": "設定{id}の保存エラー: {err}",
|
||||
"exportSuccess": "モデルを {format} として正常にエクスポートしました",
|
||||
"failedExecutionPathResolution": "選択したノードへのパスを解決できませんでした",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"tooltip": "sequential_image_generation='auto'時の最大生成画像数。総画像数(入力+生成)は15を超えることはできません。"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
"name": "model",
|
||||
"tooltip": "モデル名"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -4748,28 +4749,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageMergeTileList": {
|
||||
"display_name": "タイルのリストを画像に結合",
|
||||
"inputs": {
|
||||
"final_height": {
|
||||
"name": "final_height"
|
||||
},
|
||||
"final_width": {
|
||||
"name": "final_width"
|
||||
},
|
||||
"image_list": {
|
||||
"name": "image_list"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageOnlyCheckpointLoader": {
|
||||
"display_name": "画像のみのチェックポイントローダー(img2vidモデル)",
|
||||
"inputs": {
|
||||
@@ -5348,39 +5327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingAvatarNode": {
|
||||
"description": "1枚の写真と音声ファイルから、放送スタイルのデジタルヒューマン動画を生成します。",
|
||||
"display_name": "Kling Avatar 2.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後に制御"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "アバターの参照画像。幅と高さは300px以上である必要があります。アスペクト比は1:2.5から2.5:1の間でなければなりません。"
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "アバターの動作、感情、カメラの動きを定義するためのオプションのプロンプトです。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
|
||||
},
|
||||
"sound_file": {
|
||||
"name": "sound_file",
|
||||
"tooltip": "音声入力。2秒から300秒の間である必要があります。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KlingCameraControlI2VNode": {
|
||||
"description": "静止画像を、実際の映画撮影のようなプロフェッショナルなカメラ動作でシネマティックな動画に変換します。ズーム、回転、パン、チルト、一人称視点などのバーチャルカメラ操作を制御し、元画像へのフォーカスを維持します。",
|
||||
"display_name": "Kling 画像から動画へ(カメラコントロール)",
|
||||
@@ -14056,29 +14002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageToTileList": {
|
||||
"description": "指定したオーバーラップで画像をバッチ化されたタイルのリストに分割します。",
|
||||
"display_name": "画像をタイルのリストに分割",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"overlap": {
|
||||
"name": "overlap"
|
||||
},
|
||||
"tile_height": {
|
||||
"name": "tile_height"
|
||||
},
|
||||
"tile_width": {
|
||||
"name": "tile_width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitImageWithAlpha": {
|
||||
"display_name": "アルファで画像を分割",
|
||||
"inputs": {
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"title": "계정 생성"
|
||||
}
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionFailed": "구독 업데이트에 실패했습니다",
|
||||
"subscriptionProcessing": "결제 처리 중 — 워크스페이스를 설정하는 중입니다...",
|
||||
@@ -318,36 +315,8 @@
|
||||
"deleteBlueprint": "블루프린트 삭제",
|
||||
"deleteWorkflow": "워크플로 삭제",
|
||||
"duplicate": "복제",
|
||||
"enterAppMode": "앱 모드로 진입",
|
||||
"enterNewName": "새 이름 입력",
|
||||
"exitAppMode": "앱 모드 종료",
|
||||
"missingNodesWarning": "워크플로우에 지원되지 않는 노드가 포함되어 있습니다(빨간색으로 표시됨).",
|
||||
"workflowActions": "워크플로우 작업"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "앱",
|
||||
"appDescription": "기본적으로 앱으로 열립니다",
|
||||
"arrange": "미리보기",
|
||||
"arrangeDescription": "앱 레이아웃 검토",
|
||||
"connectOutput": "출력 연결",
|
||||
"connectOutputBody1": "앱을 저장하려면 최소 한 개의 출력이 연결되어야 합니다.",
|
||||
"connectOutputBody2": "'선택' 단계로 전환한 후 출력 노드를 클릭하여 여기에 추가하세요.",
|
||||
"filename": "파일명",
|
||||
"label": "앱 빌더",
|
||||
"nodeGraph": "노드 그래프",
|
||||
"nodeGraphDescription": "기본적으로 노드 그래프로 열립니다",
|
||||
"save": "저장",
|
||||
"saveAs": "다른 이름으로 저장",
|
||||
"saveAsLabel": "이 워크플로우를 다음으로 저장 ...",
|
||||
"saveDescription": "저장 및 완료",
|
||||
"saveSuccess": "성공적으로 저장되었습니다",
|
||||
"saveSuccessAppMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 앱 모드로 열립니다.",
|
||||
"saveSuccessAppPrompt": "지금 확인하시겠습니까?",
|
||||
"saveSuccessGraphMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 노드 그래프로 열립니다.",
|
||||
"select": "선택",
|
||||
"selectDescription": "입력/출력 선택",
|
||||
"switchToSelect": "선택으로 전환",
|
||||
"viewApp": "앱 보기"
|
||||
"missingNodesWarning": "워크플로우에 지원되지 않는 노드가 포함되어 있습니다(빨간색으로 표시됨)."
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "클립보드에 복사하지 못했습니다",
|
||||
@@ -910,7 +879,6 @@
|
||||
"enableSelected": "선택 항목 활성화",
|
||||
"enabled": "활성화됨",
|
||||
"enabling": "활성화 중",
|
||||
"enter": "입력",
|
||||
"enterBaseName": "기본 이름 입력",
|
||||
"enterNewName": "새 이름 입력",
|
||||
"enterNewNamePrompt": "새 이름을 입력하세요:",
|
||||
@@ -1181,8 +1149,6 @@
|
||||
"star": "별"
|
||||
},
|
||||
"imageCompare": {
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:",
|
||||
"noImages": "비교할 이미지가 없습니다"
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -1316,20 +1282,6 @@
|
||||
"helpFix": "이 문제 해결에 도움을 주세요"
|
||||
},
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "앱 빌더",
|
||||
"apps": "앱"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "최소 한 개",
|
||||
"connectAtLeastOne": "사용자가 실행 후 결과를 볼 수 있도록 {atLeastOne}개의 출력 노드를 연결하세요.",
|
||||
"noOutputs": "아직 출력이 추가되지 않았습니다",
|
||||
"outputExamples": "예시: '이미지 저장' 또는 '비디오 저장'",
|
||||
"outputs": "출력",
|
||||
"resultsLabel": "선택한 출력 노드에서 생성된 결과가 이곳에 표시됩니다.",
|
||||
"switchToSelect": "'선택' 단계로 전환한 후 출력 노드를 클릭하여 여기에 추가하세요.",
|
||||
"switchToSelectButton": "선택으로 전환"
|
||||
},
|
||||
"beta": "앱 모드 베타 - 피드백 보내기",
|
||||
"downloadAll": "모두 다운로드",
|
||||
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
|
||||
@@ -1339,13 +1291,11 @@
|
||||
"reuseParameters": "파라미터 재사용",
|
||||
"runCount": "실행 횟수:",
|
||||
"welcome": {
|
||||
"backToWorkflow": "워크플로우로 돌아가기",
|
||||
"buildApp": "앱 만들기",
|
||||
"controls": "출력은 하단에, 컨트롤은 오른쪽에 표시됩니다. 나머지는 모두 숨겨집니다.",
|
||||
"getStarted": "{runButton}을(를) 클릭하여 시작하세요.",
|
||||
"message": "노드 그래프를 숨겨 창작에 집중할 수 있는 간소화된 보기입니다.",
|
||||
"intro": "노드 그래프를 숨겨 창작에 집중할 수 있는 간소화된 보기입니다.",
|
||||
"layout": "왼쪽에는 생성된 이미지, 비디오, 출력물이 표시됩니다. 오른쪽에는 필요한 컨트롤만 있습니다. 복잡한 모든 것은 보이지 않습니다.",
|
||||
"sharing": "공유는 간단합니다: 워크플로우를 만들고, 앱 모드를 열고, 탭을 우클릭한 후 내보내기를 선택하세요. 다른 사람이 파일을 열면 이 깔끔한 뷰로 바로 시작됩니다. 복잡한 노드 그래프를 몰라도 강력한 워크플로우를 간단한 도구로 공유할 수 있습니다.",
|
||||
"title": "앱 모드에 오신 것을 환영합니다"
|
||||
"title": "앱 모드에 오신 것을 환영합니다",
|
||||
"widget": "어떤 설정이 표시될지 제어하려면, 최상위 노드를 서브그래프로 변환한 후 위의 툴박스에서 위젯 프로모션을 사용해 노출할 항목을 선택하세요."
|
||||
}
|
||||
},
|
||||
"load3d": {
|
||||
@@ -1895,14 +1845,6 @@
|
||||
"tooltip": "현재 ComfyUI의 나이트리 버전을 사용 중입니다. 이 기능들에 대한 의견을 피드백 버튼을 통해 공유해 주세요."
|
||||
}
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"accept": "네, 도와드릴게요!",
|
||||
"description": "이 기능을 사용해 주셨네요. 잠시 시간을 내어 피드백을 남겨주시겠어요?",
|
||||
"dontAskAgain": "다시 묻지 않기",
|
||||
"loadError": "설문을 불러오지 못했습니다. 나중에 다시 시도해 주세요.",
|
||||
"notNow": "나중에",
|
||||
"title": "개선에 도움을 주세요"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"": "",
|
||||
"3d": "3d",
|
||||
@@ -2657,17 +2599,14 @@
|
||||
"cannotDeleteGlobal": "설치된 블루프린트는 삭제할 수 없습니다",
|
||||
"confirmDelete": "이 작업은 라이브러리에서 블루프린트를 영구적으로 제거합니다",
|
||||
"confirmDeleteTitle": "블루프린트를 삭제하시겠습니까?",
|
||||
"disconnected": "연결 해제됨",
|
||||
"enterDescription": "설명을 입력하세요",
|
||||
"enterSearchAliases": "검색 별칭을 입력하세요 (쉼표로 구분)",
|
||||
"hidden": "숨김 / 중첩 매개변수",
|
||||
"hideAll": "모두 숨김",
|
||||
"linked": "(연결됨)",
|
||||
"loadFailure": "서브그래프 블루프린트 로드 실패",
|
||||
"overwriteBlueprint": "저장하면 현재 블루프린트가 변경사항으로 덮어쓰여집니다",
|
||||
"overwriteBlueprintTitle": "기존 블루프린트를 덮어쓰시겠습니까?",
|
||||
"promoteOutsideSubgraph": "하위 그래프가 아닐 때 위젯을 승격할 수 없음",
|
||||
"promoteWidget": "위젯 승격: {name}",
|
||||
"publish": "서브그래프 게시",
|
||||
"publishSuccess": "노드 라이브러리에 저장됨",
|
||||
"publishSuccessMessage": "노드 라이브러리의 \"서브그래프 블루프린트\" 아래에서 서브그래프 블루프린트를 찾을 수 있습니다",
|
||||
@@ -2675,8 +2614,7 @@
|
||||
"searchAliases": "별칭 검색",
|
||||
"showAll": "모두 표시",
|
||||
"showRecommended": "권장 위젯 표시",
|
||||
"shown": "노드에 표시됨",
|
||||
"unpromoteWidget": "위젯 승격 해제: {name}"
|
||||
"shown": "노드에 표시됨"
|
||||
},
|
||||
"subscription": {
|
||||
"addApiCredits": "API 크레딧 추가",
|
||||
@@ -2914,7 +2852,6 @@
|
||||
"emptyCanvas": "빈 캔버스",
|
||||
"errorCopyImage": "이미지 복사 오류: {error}",
|
||||
"errorLoadingModel": "모델 로딩 오류",
|
||||
"errorOpenImage": "이미지를 여는 중 오류가 발생했습니다: {error}",
|
||||
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
|
||||
"exportSuccess": "모델을 {format} 형식으로 성공적으로 내보냄",
|
||||
"failedExecutionPathResolution": "선택한 노드의 경로를 확인할 수 없음",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user