Compare commits

...

14 Commits

Author SHA1 Message Date
pythongosssss
0d61221ad3 Add tests 2025-05-10 20:56:46 +01:00
github-actions
fec5dbcf70 Update locales [skip ci] 2025-05-10 20:04:19 +01:00
pythongosssss
c72ba664ee Model file import support for desktop 2025-05-10 20:04:19 +01:00
Comfy Org PR Bot
6ed870d431 1.19.7 (#3837)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-09 19:40:11 -04:00
thot experiment
4e25a78d2d add audio preview widgets to new save nodes (#3840) 2025-05-09 19:34:37 -04:00
Christian Byrne
6408623b71 [API Node] Show user state when logged in via API key (#3838)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-05-09 14:45:32 -04:00
Comfy Org PR Bot
fdad2475ce Update locales for node definitions (#3792)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-05-09 14:11:38 -04:00
Chenlei Hu
5486fb94a0 [API Nodes] Add api pricing link to user popover (#3836)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-09 14:11:01 -04:00
Christian Byrne
34b1fd5a72 [API Node] Allow authentification via Comfy API key (#3815)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-09 13:42:03 -04:00
thot experiment
aa46524829 add workflow parsing for mp3 and opus formats (#3832) 2025-05-09 13:40:50 -04:00
filtered
3bd87820eb Fix undo / redo resets viewport (#3834) 2025-05-09 18:55:34 +10:00
filtered
0f95ed852e [TS] Fix / consolidate DOM widget types (#3830) 2025-05-09 16:24:31 +10:00
Comfy Org PR Bot
3501b480d4 [chore] Update litegraph to 0.15.8 (#3827)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-09 15:48:22 +10:00
filtered
5fa0401acd Fix workflow Export missing viewport scale/offset (#3828) 2025-05-09 14:58:16 +10:00
45 changed files with 20195 additions and 343 deletions

View File

@@ -0,0 +1,124 @@
import { test as base } from '@playwright/test'
type ElectronFixtureOptions = {
registerDefaults?: {
downloadManager?: boolean
}
}
type MockFunction = {
calls: unknown[][]
called: () => Promise<void>
handler?: (args: unknown[]) => unknown
}
export type MockElectronAPI = {
setup: (method: string, handler: (args: unknown[]) => unknown) => MockFunction
}
export const electronFixture = base.extend<{
electronAPI: MockElectronAPI
electronOptions: ElectronFixtureOptions
}>({
electronOptions: [
{
registerDefaults: {
downloadManager: true
}
},
{ option: true }
],
electronAPI: [
async ({ page, electronOptions }, use) => {
const mocks = new Map<string, MockFunction>()
await page.exposeFunction(
'__handleMockCall',
async (method: string, args: unknown[]) => {
const mock = mocks.get(method)
if (electronOptions.registerDefaults?.downloadManager) {
if (method === 'DownloadManager.getAllDownloads') {
return []
}
}
if (!mock) return null
mock.calls.push(args)
return mock.handler ? mock.handler(args) : null
}
)
const createMockFunction = (
method: string,
handler: (args: unknown[]) => unknown
): MockFunction => {
let resolveNextCall: (() => void) | null = null
const mockFn: MockFunction = {
calls: [],
async called() {
if (this.calls.length > 0) return
return new Promise<void>((resolve) => {
resolveNextCall = resolve
})
},
handler: (args: unknown[]) => {
const result = handler(args)
resolveNextCall?.()
resolveNextCall = null
return result
}
}
mocks.set(method, mockFn)
// Add the method to the window.electronAPI object
page.evaluate((methodName) => {
const w = window as typeof window & {
electronAPI: Record<string, any>
}
w.electronAPI[methodName] = async (...args: unknown[]) => {
return window['__handleMockCall'](methodName, args)
}
}, method)
return mockFn
}
const testAPI: MockElectronAPI = {
setup(method, handler) {
console.log('adding handler for', method)
return createMockFunction(method, handler)
}
}
await page.addInitScript(async () => {
const getProxy = (...path: string[]) => {
return new Proxy(() => {}, {
// Handle the proxy itself being called as a function
apply: async (target, thisArg, argArray) => {
return window['__handleMockCall'](path.join('.'), argArray)
},
// Handle property access
get: (target, prop: string) => {
return getProxy(...path, prop)
}
})
}
const w = window as typeof window & {
electronAPI: any
}
w.electronAPI = getProxy()
console.log('registered electron api')
})
await use(testAPI)
},
{ auto: true }
]
})

View File

@@ -0,0 +1,172 @@
import { expect, mergeTests } from '@playwright/test'
import { ComfyPage, comfyPageFixture } from '../fixtures/ComfyPage'
import { MockElectronAPI, electronFixture } from './fixtures/electron'
const test = mergeTests(comfyPageFixture, electronFixture)
comfyPageFixture.describe('Import Model (web)', () => {
comfyPageFixture(
'Import dialog does not show when electron api is not available',
async ({ comfyPage }) => {
await comfyPage.dragAndDropExternalResource({
fileName: 'test.bin',
buffer: Buffer.from('')
})
// Normal unable to find workflow in file error
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-warn')
).toHaveCount(1)
}
)
})
test.describe('Import Model (electron)', () => {
const dropFile = async (
comfyPage: ComfyPage,
electronAPI: MockElectronAPI,
fileName: string,
metadata: string
) => {
const getFilePathMock = electronAPI.setup('getFilePath', () =>
Promise.resolve('some/file/path/' + fileName)
)
let buffer: Buffer | undefined
if (metadata) {
const contentBuffer = Buffer.from(metadata, 'utf-8')
const headerSizeBuffer = Buffer.alloc(8)
headerSizeBuffer.writeBigUInt64LE(BigInt(contentBuffer.length))
buffer = Buffer.concat([headerSizeBuffer, contentBuffer])
}
await comfyPage.dragAndDropExternalResource({
fileName,
buffer
})
await getFilePathMock.called()
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-warn')
).toHaveCount(0)
await expect(comfyPage.importModelDialog.rootEl).toBeVisible()
}
test('Can show import file dialog by dropping file onto the app', async ({
comfyPage,
electronAPI
}) => {
await dropFile(comfyPage, electronAPI, 'test.bin', '{}')
})
test('Can autodetect checkpoint model type from modelspec', async ({
comfyPage,
electronAPI
}) => {
await dropFile(
comfyPage,
electronAPI,
'file.safetensors',
JSON.stringify({
__metadata__: {
'modelspec.sai_model_spec': 'test',
'modelspec.architecture': 'stable-diffusion-v1'
}
})
)
await expect(comfyPage.importModelDialog.modelTypeInput).toHaveValue(
'checkpoints'
)
})
test('Can autodetect lora model type from modelspec', async ({
comfyPage,
electronAPI
}) => {
await dropFile(
comfyPage,
electronAPI,
'file.safetensors',
JSON.stringify({
__metadata__: {
'modelspec.sai_model_spec': 'test',
'modelspec.architecture': 'Flux.1-AE/lora'
}
})
)
await expect(comfyPage.importModelDialog.modelTypeInput).toHaveValue(
'loras'
)
})
test('Can autodetect checkpoint model type from header keys', async ({
comfyPage,
electronAPI
}) => {
await dropFile(
comfyPage,
electronAPI,
'file.safetensors',
JSON.stringify({
'model.diffusion_model.input_blocks.0.0.bias': {}
})
)
await expect(comfyPage.importModelDialog.modelTypeInput).toHaveValue(
'checkpoints'
)
})
test('Can autodetect lora model type from header keys', async ({
comfyPage,
electronAPI
}) => {
await dropFile(
comfyPage,
electronAPI,
'file.safetensors',
JSON.stringify({
'lora_unet_down_blocks_0_attentions_0_proj_in.alpha': {}
})
)
await expect(comfyPage.importModelDialog.modelTypeInput).toHaveValue(
'loras'
)
})
test('Can import file', async ({ comfyPage, electronAPI }) => {
await dropFile(
comfyPage,
electronAPI,
'checkpoint_modelspec.safetensors',
'{}'
)
const importModelMock = electronAPI.setup(
'importModel',
() => new Promise((resolve) => setTimeout(resolve, 100))
)
// Model type is required so select one
await expect(comfyPage.importModelDialog.importButton).toBeDisabled()
await comfyPage.importModelDialog.modelTypeInput.fill('checkpoints')
await expect(comfyPage.importModelDialog.importButton).toBeEnabled()
// Click import, ensure API is called
await comfyPage.importModelDialog.importButton.click()
await importModelMock.called()
// Toast should be shown and dialog closes
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-success')
).toHaveCount(1)
await expect(comfyPage.importModelDialog.rootEl).toBeHidden()
})
})

View File

@@ -13,6 +13,7 @@ import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { ImportModelDialog } from './components/ImportModelDialog'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
@@ -140,6 +141,7 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly importModelDialog: ImportModelDialog
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -165,6 +167,7 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page)
this.confirmDialog = new ConfirmDialog(page)
this.importModelDialog = new ImportModelDialog(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -469,6 +472,7 @@ export class ComfyPage {
fileName?: string
url?: string
dropPosition?: Position
buffer?: Buffer
} = {}
) {
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
@@ -487,7 +491,7 @@ export class ComfyPage {
// Dropping a file from the filesystem
if (fileName) {
const filePath = this.assetPath(fileName)
const buffer = fs.readFileSync(filePath)
const buffer = options.buffer ?? fs.readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'

View File

@@ -0,0 +1,17 @@
import { Page } from '@playwright/test'
export class ImportModelDialog {
constructor(public readonly page: Page) {}
get rootEl() {
return this.page.locator('div[aria-labelledby="global-import-model"]')
}
get modelTypeInput() {
return this.rootEl.locator('#model-type')
}
get importButton() {
return this.rootEl.getByLabel('Import')
}
}

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.19.6",
"version": "1.19.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.19.6",
"version": "1.19.7",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.7",
"@comfyorg/litegraph": "^0.15.8",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -482,9 +482,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.15.7",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.7.tgz",
"integrity": "sha512-Z1NKx5OgGGcoKx6lB/r81Yhl+DhPFg5QYIgGqAjVEzy5/G5fQtX9k9WZL3oZoP89xEhT7BT931To7pDb3cuAYQ==",
"version": "0.15.8",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.8.tgz",
"integrity": "sha512-pW3W9c9wQsSOultjzRZqSxcXv07sppFybGsEdfnPqLYXqlJcrsysU0NbPTSaZqOAJdFHMu2d0J6P02Id4oFB/w==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.19.6",
"version": "1.19.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -72,7 +72,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.7",
"@comfyorg/litegraph": "^0.15.8",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -16,8 +16,10 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { api } from '@/scripts/api'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useDialogService } from './services/dialogService'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
@@ -46,6 +48,20 @@ onMounted(() => {
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)
// Handle file drops to import models via electron
api.addEventListener('unhandledFileDrop', async (e) => {
e.preventDefault() // Prevent unable to find workflow in file error
const filePath = await electronAPI()['getFilePath'](e.detail.file)
if (filePath) {
useDialogService().showImportModelDialog({
path: filePath,
file: e.detail.file
})
}
})
}
})
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="px-4 py-2 h-full gap-2 flex flex-col">
<h2 class="text-4xl font-normal my-0">
{{ t('importModelDialog.title') }}
</h2>
<span class="text-muted">{{ path }}</span>
<div class="flex flex-col gap-2 mt-4">
<IftaLabel>
<Select
v-model="selectedType"
:options="modelFolders"
editable
filter
labelId="model-type"
:disabled="importing"
/>
<label for="model-type">Type</label>
</IftaLabel>
</div>
<Message severity="error" v-if="importError">{{ importError }}</Message>
</div>
<footer>
<div class="flex justify-between gap-2 p-4">
<SelectButton
v-model="selectedImportMode"
optionLabel="label"
optionValue="value"
:options="importModes"
:disabled="importing"
/>
<div class="flex gap-2">
<Button
type="button"
label="Cancel"
severity="secondary"
@click="dialogStore.closeDialog()"
:disabled="importing"
></Button>
<Button
type="button"
label="Import"
@click="importModel()"
:icon="importIcon"
:loading="importing"
:disabled="!selectedType"
></Button>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import IftaLabel from 'primevue/iftalabel'
import Message from 'primevue/message'
import Select from 'primevue/select'
import SelectButton from 'primevue/selectbutton'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelStore } from '@/stores/modelStore'
import { electronAPI } from '@/utils/envUtil'
import { guessModelType } from '@/utils/safetensorsUtil'
const { t } = useI18n()
const dialogStore = useDialogStore()
const { path, file } = defineProps<{
path: string
file: File
}>()
const importModes = ref([
{ label: t('importModelDialog.move'), value: 'move' },
{ label: t('importModelDialog.copy'), value: 'copy' }
])
const modelStore = useModelStore()
const modelFolders = ref<string[]>()
const selectedType = ref<string>()
const selectedImportMode = ref<string>('move')
const importing = ref<boolean>(false)
const importError = ref<string>()
const importIcon = computed(() => {
return selectedImportMode.value === 'move'
? 'pi pi-file-import'
: 'pi pi-copy'
})
const importModel = async () => {
importing.value = true
try {
await electronAPI()?.['importModel'](
file,
selectedType.value,
selectedImportMode.value
)
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
dialogStore.closeDialog()
} catch (error) {
console.error(error)
importError.value = error.message
} finally {
importing.value = false
}
}
const init = async () => {
if (!modelStore.modelFolders.length) {
await modelStore.loadModelFolders()
}
modelFolders.value = modelStore.modelFolders.map((folder) => folder.directory)
const type = await guessModelType(file)
if (!selectedType.value) {
selectedType.value = type
}
}
init()
</script>

View File

@@ -1,95 +1,132 @@
<template>
<div class="w-96 p-2">
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<div class="w-96 p-2 overflow-x-hidden">
<ApiKeyForm
v-if="showApiKeyForm"
@back="showApiKeyForm = false"
@success="onSuccess"
/>
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="toggleState"
>{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="showApiKeyForm = true"
>
<img
src="/assets/images/comfy-logo-mono.svg"
class="w-5 h-5 mr-2"
alt="Comfy"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-muted text-center">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
</div>
<!-- Terms & Contact -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
</p>
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</div>
<!-- Terms & Contact -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
</p>
</div>
</template>
@@ -104,6 +141,7 @@ import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
@@ -115,8 +153,11 @@ const { t } = useI18n()
const authService = useFirebaseAuthService()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const toggleState = () => {
isSignIn.value = !isSignIn.value
showApiKeyForm.value = false
}
const signInWithGoogle = async () => {

View File

@@ -4,10 +4,11 @@
<h2 class="text-2xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
<div v-if="user" class="flex flex-col gap-2">
<!-- Normal User Panel -->
<div v-if="isLoggedIn" class="flex flex-col gap-2">
<UserAvatar
v-if="user.photoURL"
:photo-url="user.photoURL"
v-if="userPhotoUrl"
:photo-url="userPhotoUrl"
shape="circle"
size="large"
/>
@@ -17,7 +18,7 @@
{{ $t('userSettings.name') }}
</h3>
<div class="text-muted">
{{ user.displayName || $t('userSettings.notSet') }}
{{ userDisplayName || $t('userSettings.notSet') }}
</div>
</div>
@@ -25,8 +26,8 @@
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<a :href="'mailto:' + user.email" class="hover:underline">
{{ user.email }}
<a :href="'mailto:' + userEmail" class="hover:underline">
{{ userEmail }}
</a>
</div>
@@ -90,51 +91,22 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const user = computed(() => authStore.currentUser)
const loading = computed(() => authStore.loading)
const providerName = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const isEmailProvider = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
return providerId === 'password'
})
const handleSignOut = async () => {
await commandStore.execute('Comfy.User.SignOut')
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
const {
loading,
isLoggedIn,
isEmailProvider,
userDisplayName,
userEmail,
userPhotoUrl,
providerName,
providerIcon,
handleSignOut,
handleSignIn
} = useCurrentUser()
</script>

View File

@@ -0,0 +1,114 @@
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: vi.fn(() => ({
storeApiKey: mockStoreApiKey
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
auth: {
apiKey: {
title: 'API Key',
label: 'API Key',
placeholder: 'Enter your API Key',
error: 'Invalid API Key',
helpText: 'Need an API key?',
generateKey: 'Get one here',
whitelistInfo: 'About non-whitelisted sites'
}
},
g: {
back: 'Back',
save: 'Save',
learnMore: 'Learn more'
}
}
}
})
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoading.mockReset()
})
const mountComponent = (props: any = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: { Button, Form, InputText, Message }
},
props
})
}
it('renders correctly with all required elements', () => {
const wrapper = mountComponent()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('emits back event when back button is clicked', async () => {
const wrapper = mountComponent()
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
})
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const submitButton = wrapper
.findAllComponents(Button)
.find((btn) => btn.text() === 'Save')
expect(submitButton?.props('loading')).toBe(true)
})
it('displays help text and links correctly', () => {
const wrapper = mountComponent()
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
'https://platform.comfy.org/login'
)
})
})

View File

@@ -0,0 +1,111 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ t('auth.apiKey.title') }}
</h1>
<div class="flex flex-col gap-2">
<p class="text-base my-0 text-muted">
{{ t('auth.apiKey.description') }}
</p>
<a
href="https://docs.comfy.org/interface/user#logging-in-with-an-api-key"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('g.learnMore') }}
</a>
</div>
</div>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(apiKeySchema)"
@submit="onSubmit"
>
<Message v-if="$form.apiKey?.invalid" severity="error" class="mb-4">
{{ $form.apiKey.error.message }}
</Message>
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-api-key"
>
{{ t('auth.apiKey.label') }}
</label>
<div class="flex flex-col gap-2">
<InputText
pt:root:id="comfy-org-api-key"
pt:root:autocomplete="off"
class="h-10"
name="apiKey"
type="password"
:placeholder="t('auth.apiKey.placeholder')"
:invalid="$form.apiKey?.invalid"
/>
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
<span class="mx-1"></span>
<a
href="https://docs.comfy.org/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.whitelistInfo') }}
</a>
</small>
</div>
</div>
<div class="flex justify-between items-center mt-4">
<Button type="button" link @click="$emit('back')">
{{ t('g.back') }}
</Button>
<Button type="submit" :loading="loading" :disabled="loading">
{{ t('g.save') }}
</Button>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
const emit = defineEmits<{
(e: 'back'): void
(e: 'success'): void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
await apiKeyStore.storeApiKey(event.values.apiKey)
emit('success')
}
}
</script>

View File

@@ -2,7 +2,7 @@
<template>
<div>
<Button
v-if="isAuthenticated"
v-if="isLoggedIn"
class="user-profile-button p-1"
severity="secondary"
text
@@ -30,15 +30,14 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import CurrentUserPopover from './CurrentUserPopover.vue'
const authStore = useFirebaseAuthStore()
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const photoURL = computed<string | undefined>(
() => authStore.currentUser?.photoURL ?? undefined
() => userPhotoUrl.value ?? undefined
)
</script>

View File

@@ -6,19 +6,19 @@
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="user?.photoURL"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'!text-2xl': !user?.photoURL
'!text-2xl': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="text-lg font-semibold truncate my-0 mb-1">
{{ user?.displayName || $t('g.user') }}
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="user?.email" class="text-sm text-muted truncate my-0">
{{ user.email }}
<p v-if="userEmail" class="text-sm text-muted truncate my-0">
{{ userEmail }}
</p>
</div>
</div>
@@ -37,6 +37,18 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
icon="pi pi-external-link"
text
fluid
severity="secondary"
@click="handleOpenApiPricing"
/>
<Divider class="my-2" />
<div class="w-full flex flex-col gap-2 p-2">
<div class="text-muted text-sm">
{{ $t('credits.yourCreditBalance') }}
@@ -52,20 +64,18 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { computed, onMounted } from 'vue'
import { onMounted } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authService = useFirebaseAuthService()
const dialogService = useDialogService()
const user = computed(() => authStore.currentUser)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
}
@@ -74,6 +84,10 @@ const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
}
onMounted(() => {
void authService.fetchBalance()
})

View File

@@ -0,0 +1,101 @@
import { computed } from 'vue'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
const isLoggedIn = computed(
() => !!isApiKeyLogin.value || firebaseUser.value !== null
)
const userDisplayName = computed(() => {
if (isApiKeyLogin.value) {
return apiKeyStore.currentUser?.name
}
return firebaseUser.value?.displayName
})
const userEmail = computed(() => {
if (isApiKeyLogin.value) {
return apiKeyStore.currentUser?.email
}
return firebaseUser.value?.email
})
const providerName = computed(() => {
if (isApiKeyLogin.value) {
return 'Comfy API Key'
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
if (isApiKeyLogin.value) {
return 'pi pi-key'
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const isEmailProvider = computed(() => {
if (isApiKeyLogin.value) {
return false
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
return providerId === 'password'
})
const userPhotoUrl = computed(() => {
if (isApiKeyLogin.value) return null
return firebaseUser.value?.photoURL
})
const handleSignOut = async () => {
if (isApiKeyLogin.value) {
await apiKeyStore.clearStoredApiKey()
} else {
await commandStore.execute('Comfy.User.SignOut')
}
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
return {
loading: authStore.loading,
isLoggedIn,
isApiKeyLogin,
isEmailProvider,
userDisplayName,
userEmail,
userPhotoUrl,
providerName,
providerIcon,
handleSignOut,
handleSignIn
}
}

View File

@@ -7,13 +7,14 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import type { SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useCurrentUser } from '../auth/useCurrentUser'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
@@ -29,7 +30,7 @@ export function useSettingUI(
| 'credits'
) {
const { t } = useI18n()
const firebaseAuthStore = useFirebaseAuthStore()
const { isLoggedIn } = useCurrentUser()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
@@ -165,7 +166,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(firebaseAuthStore.isAuthenticated ? [creditsPanel.node] : [])
...(isLoggedIn.value ? [creditsPanel.node] : [])
].map(translateCategory)
},
// Normal settings stored in the settingStore

View File

@@ -90,7 +90,13 @@ app.registerExtension({
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (
['LoadAudio', 'SaveAudio', 'PreviewAudio'].includes(
[
'LoadAudio',
'SaveAudio',
'PreviewAudio',
'SaveAudioMP3',
'SaveAudioOpus'
].includes(
// @ts-expect-error fixme ts strict error
nodeType.prototype.comfyClass
)

View File

@@ -1022,6 +1022,7 @@
"image": "image",
"preprocessors": "preprocessors",
"advanced": "advanced",
"guidance": "guidance",
"loaders": "loaders",
"model_merging": "model_merging",
"attention_experiments": "attention_experiments",
@@ -1034,44 +1035,64 @@
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"video": "video",
"mask": "mask",
"deprecated": "deprecated",
"latent": "latent",
"video": "video",
"audio": "audio",
"3d": "3d",
"ltxv": "ltxv",
"sd3": "sd3",
"sigmas": "sigmas",
"api node": "api node",
"BFL": "BFL",
"model_patches": "model_patches",
"unet": "unet",
"gligen": "gligen",
"video_models": "video_models",
"Ideogram": "Ideogram",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"postprocessing": "postprocessing",
"transform": "transform",
"batch": "batch",
"upscaling": "upscaling",
"instructpix2pix": "instructpix2pix",
"compositing": "compositing",
"Kling": "Kling",
"samplers": "samplers",
"operations": "operations",
"lotus": "lotus",
"Luma": "Luma",
"MiniMax": "MiniMax",
"debug": "debug",
"model": "model",
"model_specific": "model_specific",
"OpenAI": "OpenAI",
"cond pair": "cond pair",
"photomaker": "photomaker",
"Pika": "Pika",
"PixVerse": "PixVerse",
"utils": "utils",
"primitive": "primitive",
"Recraft": "Recraft",
"animation": "animation",
"api": "api",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"guidance": "guidance",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model"
"style_model": "style_model",
"sd": "sd",
"Veo": "Veo"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
"BOOLEAN": "BOOLEAN",
"CAMERA_CONTROL": "CAMERA_CONTROL",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
@@ -1090,18 +1111,27 @@
"LATENT_OPERATION": "LATENT_OPERATION",
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
"LUMA_CONCEPTS": "LUMA_CONCEPTS",
"LUMA_REF": "LUMA_REF",
"MASK": "MASK",
"MESH": "MESH",
"MODEL": "MODEL",
"NOISE": "NOISE",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
"RECRAFT_COLOR": "RECRAFT_COLOR",
"RECRAFT_CONTROLS": "RECRAFT_CONTROLS",
"RECRAFT_V3_STYLE": "RECRAFT_V3_STYLE",
"SAMPLER": "SAMPLER",
"SIGMAS": "SIGMAS",
"STRING": "STRING",
"STYLE_MODEL": "STYLE_MODEL",
"SVG": "SVG",
"TIMESTEPS_RANGE": "TIMESTEPS_RANGE",
"UPSCALE_MODEL": "UPSCALE_MODEL",
"VAE": "VAE",
"VIDEO": "VIDEO",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
@@ -1232,8 +1262,27 @@
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist."
},
"auth": {
"apiKey": {
"title": "API Key",
"label": "API Key",
"description": "Use your Comfy API key to enable API Nodes",
"placeholder": "Enter your API Key",
"error": "Invalid API Key",
"storageFailed": "Failed to store API Key",
"storageFailedDetail": "Please try again.",
"stored": "API Key stored",
"storedDetail": "Your API Key has been stored successfully",
"cleared": "API Key cleared",
"clearedDetail": "Your API Key has been cleared successfully",
"invalid": "Invalid API Key",
"invalidDetail": "Please enter a valid API Key",
"helpText": "Need an API key?",
"generateKey": "Get one here",
"whitelistInfo": "About non-whitelisted sites"
},
"login": {
"title": "Log in to your account",
"useApiKey": "Comfy API Key",
"signInOrSignUp": "Sign In / Sign Up",
"forgotPasswordError": "Failed to send password reset email",
"passwordResetSent": "Password reset email sent",
@@ -1259,7 +1308,8 @@
"success": "Login successful",
"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"
"questionsContactPrefix": "Questions? Contact us at",
"noAssociatedUser": "There is no Comfy user associated with the provided API key"
},
"signup": {
"title": "Create an account",
@@ -1290,6 +1340,8 @@
"required": "Required",
"minLength": "Must be at least {length} characters",
"maxLength": "Must be no more than {length} characters",
"prefix": "Must start with {prefix}",
"length": "Must be {length} characters",
"password": {
"requirements": "Password requirements",
"minLength": "Must be between 8 and 32 characters",
@@ -1306,6 +1358,7 @@
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
"invoiceHistory": "Invoice History",
"apiPricing": "API Pricing",
"faqs": "FAQs",
"messageSupport": "Message Support",
"lastUpdated": "Last updated",
@@ -1352,5 +1405,11 @@
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
"disabledTooltip": "No output nodes selected"
}
},
"importModelDialog": {
"title": "Import Model",
"type": "Type",
"move": "Move",
"copy": "Copy"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"auth": {
"apiKey": {
"cleared": "Clave API eliminada",
"clearedDetail": "Tu clave API se ha eliminado correctamente",
"description": "Usa tu clave API de Comfy para habilitar los nodos de API",
"error": "Clave API no válida",
"generateKey": "Consíguela aquí",
"helpText": "¿Necesitas una clave API?",
"invalid": "Clave API no válida",
"invalidDetail": "Por favor, introduce una clave API válida",
"label": "Clave API",
"placeholder": "Introduce tu clave API",
"storageFailed": "No se pudo guardar la clave API",
"storageFailedDetail": "Por favor, inténtalo de nuevo.",
"stored": "Clave API guardada",
"storedDetail": "Tu clave API se ha guardado correctamente",
"title": "Clave API",
"whitelistInfo": "Acerca de los sitios no incluidos en la lista blanca"
},
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
@@ -43,6 +61,7 @@
"loginWithGithub": "Iniciar sesión con Github",
"loginWithGoogle": "Iniciar sesión con Google",
"newUser": "¿Eres nuevo aquí?",
"noAssociatedUser": "No hay ningún usuario de Comfy asociado con la clave API proporcionada",
"orContinueWith": "O continuar con",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
@@ -56,6 +75,7 @@
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta",
"useApiKey": "Clave API de Comfy",
"userAvatar": "Avatar de usuario"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "Desanclar"
},
"credits": {
"apiPricing": "Precios de la API",
"credits": "Créditos",
"faqs": "Preguntas frecuentes",
"invoiceHistory": "Historial de facturas",
@@ -149,8 +170,10 @@
"yourCreditBalance": "Tu saldo de créditos"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
"BOOLEAN": "BOOLEANO",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "SALIDA_CLIP_VISION",
@@ -167,20 +190,29 @@
"INT": "ENTERO",
"LATENT": "LATENTE",
"LATENT_OPERATION": "OPERACIÓN_LATENTE",
"LOAD3D_CAMERA": "CARGAR CÁMARA 3D",
"LOAD_3D": "CARGAR_3D",
"LOAD_3D_ANIMATION": "CARGAR_ANIMACIÓN_3D",
"LUMA_CONCEPTS": "CONCEPTOS LUMA",
"LUMA_REF": "REFERENCIA LUMA",
"MASK": "MASK",
"MESH": "MALLA",
"MODEL": "MODELO",
"NOISE": "RUIDO",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "PLANTILLA PIXVERSE",
"RECRAFT_COLOR": "COLOR RECRAFT",
"RECRAFT_CONTROLS": "CONTROLES RECRAFT",
"RECRAFT_V3_STYLE": "ESTILO RECRAFT V3",
"SAMPLER": "MUESTREADOR",
"SIGMAS": "SIGMAS",
"STRING": "CADENA",
"STYLE_MODEL": "MODELO_DE_ESTILO",
"SVG": "SVG",
"TIMESTEPS_RANGE": "RANGO_DE_PASOS_DE_TIEMPO",
"UPSCALE_MODEL": "MODELO_DE_ESCALADO",
"VAE": "VAE",
"VIDEO": "VÍDEO",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
@@ -732,10 +764,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "modelos_3d",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
"api": "api",
"api node": "nodo api",
"attention_experiments": "experimentos_de_atención",
"audio": "audio",
"batch": "lote",
@@ -760,6 +804,7 @@
"instructpix2pix": "instruirpix2pix",
"latent": "latent",
"loaders": "cargadores",
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "mask",
"model": "modelo",
@@ -771,10 +816,12 @@
"photomaker": "photomaker",
"postprocessing": "postprocesamiento",
"preprocessors": "preprocesadores",
"primitive": "primitivo",
"samplers": "muestreadores",
"sampling": "muestreo",
"schedulers": "programadores",
"scheduling": "programación",
"sd": "sd",
"sd3": "sd3",
"sigmas": "sigmas",
"stable_cascade": "stable_cascade",
@@ -783,6 +830,10 @@
"unet": "unet",
"upscale_diffusion": "difusión_de_escalado",
"upscaling": "escalado",
"utils": "utilidades",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "video",
"video_models": "modelos_de_video"
},
@@ -1330,6 +1381,7 @@
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",
"length": "Debe tener {length} caracteres",
"maxLength": "No debe tener más de {length} caracteres",
"minLength": "Debe tener al menos {length} caracteres",
"password": {
@@ -1342,6 +1394,7 @@
"uppercase": "Debe contener al menos una letra mayúscula"
},
"personalDataConsentRequired": "Debes aceptar el procesamiento de tus datos personales.",
"prefix": "Debe comenzar con {prefix}",
"required": "Requerido"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "Connexion requise pour utiliser les nœuds API"
},
"auth": {
"apiKey": {
"cleared": "Clé API supprimée",
"clearedDetail": "Votre clé API a été supprimée avec succès",
"description": "Utilisez votre clé API Comfy pour activer les nœuds API",
"error": "Clé API invalide",
"generateKey": "Obtenez-en une ici",
"helpText": "Besoin d'une clé API ?",
"invalid": "Clé API invalide",
"invalidDetail": "Veuillez entrer une clé API valide",
"label": "Clé API",
"placeholder": "Entrez votre clé API",
"storageFailed": "Échec de lenregistrement de la clé API",
"storageFailedDetail": "Veuillez réessayer.",
"stored": "Clé API enregistrée",
"storedDetail": "Votre clé API a été enregistrée avec succès",
"title": "Clé API",
"whitelistInfo": "À propos des sites non autorisés"
},
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
@@ -43,6 +61,7 @@
"loginWithGithub": "Se connecter avec Github",
"loginWithGoogle": "Se connecter avec Google",
"newUser": "Nouveau ici?",
"noAssociatedUser": "Aucun utilisateur Comfy n'est associé à la clé API fournie",
"orContinueWith": "Ou continuer avec",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
@@ -56,6 +75,7 @@
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte",
"useApiKey": "Clé API Comfy",
"userAvatar": "Avatar utilisateur"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "Désépingler"
},
"credits": {
"apiPricing": "Tarification de lAPI",
"credits": "Crédits",
"faqs": "FAQ",
"invoiceHistory": "Historique des factures",
@@ -149,8 +170,10 @@
"yourCreditBalance": "Votre solde de crédits"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
"BOOLEAN": "BOOLEAN",
"CAMERA_CONTROL": "Contrôle de la caméra",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "SORTIE_CLIP_VISION",
@@ -167,20 +190,29 @@
"INT": "ENTIER",
"LATENT": "LATENT",
"LATENT_OPERATION": "OPERATION_LATENTE",
"LOAD3D_CAMERA": "Charger la caméra 3D",
"LOAD_3D": "CHARGER_3D",
"LOAD_3D_ANIMATION": "CHARGER_ANIMATION_3D",
"LUMA_CONCEPTS": "Concepts Luma",
"LUMA_REF": "Référence Luma",
"MASK": "MASQUE",
"MESH": "MAILLAGE",
"MODEL": "MODÈLE",
"NOISE": "BRUIT",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "Modèle Pixverse",
"RECRAFT_COLOR": "Couleur Recraft",
"RECRAFT_CONTROLS": "Contrôles Recraft",
"RECRAFT_V3_STYLE": "Style Recraft V3",
"SAMPLER": "ÉCHANTILLONNEUR",
"SIGMAS": "SIGMAS",
"STRING": "CHAÎNE",
"STYLE_MODEL": "MODÈLE_DE_STYLE",
"SVG": "SVG",
"TIMESTEPS_RANGE": "PLAGE_DES_ÉTAPES_TEMPORELLES",
"UPSCALE_MODEL": "MODÈLE_DE_MISE_À_L'ÉCHELLE",
"VAE": "VAE",
"VIDEO": "Vidéo",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
@@ -364,6 +396,12 @@
"inbox": "Boîte de réception",
"star": "Étoile"
},
"importModelDialog": {
"copy": "Copier",
"move": "Déplacer",
"title": "Importer le modèle",
"type": "Type"
},
"install": {
"appDataLocationTooltip": "Répertoire des données de l'application ComfyUI. Stocke :\n- Logs\n- Configurations du serveur",
"appPathLocationTooltip": "Répertoire des ressources de l'application ComfyUI. Stocke le code et les ressources de ComfyUI",
@@ -732,10 +770,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "modèles_3d",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_pour_test",
"advanced": "avancé",
"animation": "animation",
"api": "api",
"api node": "nœud api",
"attention_experiments": "expériences_d'attention",
"audio": "audio",
"batch": "lot",
@@ -760,6 +810,7 @@
"instructpix2pix": "instructpix2pix",
"latent": "latent",
"loaders": "chargeurs",
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "masque",
"model": "modèle",
@@ -771,10 +822,12 @@
"photomaker": "photomaker",
"postprocessing": "post-traitement",
"preprocessors": "préprocesseurs",
"primitive": "primitif",
"samplers": "échantillonneurs",
"sampling": "échantillonnage",
"schedulers": "planificateurs",
"scheduling": "planification",
"sd": "sd",
"sd3": "sd3",
"sigmas": "sigmas",
"stable_cascade": "stable_cascade",
@@ -783,6 +836,10 @@
"unet": "unet",
"upscale_diffusion": "diffusion_de_mise_à_l'échelle",
"upscaling": "mise_à_l'échelle",
"utils": "utilitaires",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "vidéo",
"video_models": "modèles_vidéo"
},
@@ -1330,6 +1387,7 @@
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",
"length": "Doit comporter {length} caractères",
"maxLength": "Ne doit pas dépasser {length} caractères",
"minLength": "Doit contenir au moins {length} caractères",
"password": {
@@ -1342,6 +1400,7 @@
"uppercase": "Doit contenir au moins une lettre majuscule"
},
"personalDataConsentRequired": "Vous devez accepter le traitement de vos données personnelles.",
"prefix": "Doit commencer par {prefix}",
"required": "Requis"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "APIードを使用するためにはサインインが必要です"
},
"auth": {
"apiKey": {
"cleared": "APIキーが削除されました",
"clearedDetail": "APIキーが正常に削除されました",
"description": "Comfy APIキーを使用してAPIードを有効にします",
"error": "無効なAPIキーです",
"generateKey": "こちらから取得",
"helpText": "APIキーが必要ですか",
"invalid": "無効なAPIキーです",
"invalidDetail": "有効なAPIキーを入力してください",
"label": "APIキー",
"placeholder": "APIキーを入力してください",
"storageFailed": "APIキーの保存に失敗しました",
"storageFailedDetail": "もう一度お試しください。",
"stored": "APIキーが保存されました",
"storedDetail": "APIキーが正常に保存されました",
"title": "APIキー",
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
},
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
@@ -43,6 +61,7 @@
"loginWithGithub": "Githubでログイン",
"loginWithGoogle": "Googleでログイン",
"newUser": "新規ユーザーですか?",
"noAssociatedUser": "指定されたAPIキーに関連付けられたComfyユーザーが存在しません",
"orContinueWith": "または以下で続ける",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
@@ -56,6 +75,7 @@
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする",
"useApiKey": "Comfy APIキー",
"userAvatar": "ユーザーアバター"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "ピンを解除"
},
"credits": {
"apiPricing": "API料金",
"credits": "クレジット",
"faqs": "よくある質問",
"invoiceHistory": "請求履歴",
@@ -149,8 +170,10 @@
"yourCreditBalance": "あなたのクレジット残高"
},
"dataTypes": {
"*": "*",
"AUDIO": "オーディオ",
"BOOLEAN": "ブール",
"CAMERA_CONTROL": "カメラコントロール",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
@@ -167,20 +190,29 @@
"INT": "整数",
"LATENT": "潜在",
"LATENT_OPERATION": "潜在操作",
"LOAD3D_CAMERA": "3Dカメラの読み込み",
"LOAD_3D": "3Dをロード",
"LOAD_3D_ANIMATION": "3Dアニメーションをロード",
"LUMA_CONCEPTS": "Lumaコンセプト",
"LUMA_REF": "Luma参照",
"MASK": "マスク",
"MESH": "メッシュ",
"MODEL": "モデル",
"NOISE": "ノイズ",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "Pixverseテンプレート",
"RECRAFT_COLOR": "Recraftカラー",
"RECRAFT_CONTROLS": "Recraftコントロール",
"RECRAFT_V3_STYLE": "Recraft V3スタイル",
"SAMPLER": "サンプラー",
"SIGMAS": "シグマ",
"STRING": "文字列",
"STYLE_MODEL": "スタイルモデル",
"SVG": "SVG",
"TIMESTEPS_RANGE": "タイムステップの範囲",
"UPSCALE_MODEL": "アップスケールモデル",
"VAE": "VAE",
"VIDEO": "ビデオ",
"VOXEL": "ボクセル",
"WEBCAM": "ウェブカメラ"
},
@@ -364,6 +396,12 @@
"inbox": "受信トレイ",
"star": "星"
},
"importModelDialog": {
"copy": "コピー",
"move": "移動",
"title": "モデルをインポート",
"type": "タイプ"
},
"install": {
"appDataLocationTooltip": "ComfyUIのアプリデータディレクトリ。保存内容:\n- ログ\n- サーバー設定",
"appPathLocationTooltip": "ComfyUIのアプリ資産ディレクトリ。ComfyUIのコードとアセットを保存します",
@@ -732,10 +770,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "3Dモデル",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_テスト用",
"advanced": "高度な機能",
"animation": "アニメーション",
"api": "API",
"api node": "apiード",
"attention_experiments": "アテンション実験",
"audio": "オーディオ",
"batch": "バッチ",
@@ -760,6 +810,7 @@
"instructpix2pix": "インストラクションピクス2ピクス",
"latent": "潜在",
"loaders": "ローダー",
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "マスク",
"model": "モデル",
@@ -771,10 +822,12 @@
"photomaker": "photomaker",
"postprocessing": "ポストプロセッシング",
"preprocessors": "前処理",
"primitive": "プリミティブ",
"samplers": "サンプラー",
"sampling": "サンプリング",
"schedulers": "スケジューラー",
"scheduling": "スケジューリング",
"sd": "sd",
"sd3": "SD3",
"sigmas": "シグマ",
"stable_cascade": "安定したカスケード",
@@ -783,6 +836,10 @@
"unet": "U-Net",
"upscale_diffusion": "アップスケール拡散",
"upscaling": "アップスケーリング",
"utils": "ユーティリティ",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "ビデオ",
"video_models": "ビデオモデル"
},
@@ -1330,6 +1387,7 @@
},
"validation": {
"invalidEmail": "無効なメールアドレス",
"length": "{length}文字でなければなりません",
"maxLength": "{length}文字以下でなければなりません",
"minLength": "{length}文字以上でなければなりません",
"password": {
@@ -1342,6 +1400,7 @@
"uppercase": "少なくとも1つの大文字を含む必要があります"
},
"personalDataConsentRequired": "個人データの処理に同意する必要があります。",
"prefix": "{prefix}で始める必要があります",
"required": "必須"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "API 노드 사용에 필요한 로그인"
},
"auth": {
"apiKey": {
"cleared": "API 키 삭제됨",
"clearedDetail": "API 키가 성공적으로 삭제되었습니다",
"description": "Comfy API 키를 사용하여 API 노드를 활성화하세요",
"error": "유효하지 않은 API 키",
"generateKey": "여기에서 받기",
"helpText": "API 키가 필요하신가요?",
"invalid": "유효하지 않은 API 키",
"invalidDetail": "유효한 API 키를 입력해 주세요",
"label": "API 키",
"placeholder": "API 키를 입력하세요",
"storageFailed": "API 키 저장 실패",
"storageFailedDetail": "다시 시도해 주세요.",
"stored": "API 키 저장됨",
"storedDetail": "API 키가 성공적으로 저장되었습니다",
"title": "API 키",
"whitelistInfo": "비허용 사이트에 대하여"
},
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
@@ -43,6 +61,7 @@
"loginWithGithub": "Github로 로그인",
"loginWithGoogle": "구글로 로그인",
"newUser": "처음이신가요?",
"noAssociatedUser": "제공된 API 키와 연결된 Comfy 사용자가 없습니다",
"orContinueWith": "또는 다음으로 계속",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
@@ -56,6 +75,7 @@
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인",
"useApiKey": "Comfy API 키",
"userAvatar": "사용자 아바타"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "고정 해제"
},
"credits": {
"apiPricing": "API 가격",
"credits": "크레딧",
"faqs": "자주 묻는 질문",
"invoiceHistory": "청구서 내역",
@@ -149,8 +170,10 @@
"yourCreditBalance": "보유 크레딧 잔액"
},
"dataTypes": {
"*": "*",
"AUDIO": "오디오",
"BOOLEAN": "논리값",
"CAMERA_CONTROL": "카메라 제어",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION 출력",
@@ -167,20 +190,29 @@
"INT": "정수",
"LATENT": "잠재 데이터",
"LATENT_OPERATION": "잠재 연산",
"LOAD3D_CAMERA": "3D 카메라 불러오기",
"LOAD_3D": "3D 로드",
"LOAD_3D_ANIMATION": "3D 애니메이션 로드",
"LUMA_CONCEPTS": "Luma 컨셉",
"LUMA_REF": "Luma 참조",
"MASK": "마스크",
"MESH": "메시",
"MODEL": "모델",
"NOISE": "노이즈",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "Pixverse 템플릿",
"RECRAFT_COLOR": "Recraft 색상",
"RECRAFT_CONTROLS": "Recraft 컨트롤",
"RECRAFT_V3_STYLE": "Recraft V3 스타일",
"SAMPLER": "샘플러",
"SIGMAS": "시그마",
"STRING": "문자열",
"STYLE_MODEL": "스타일 모델",
"SVG": "SVG",
"TIMESTEPS_RANGE": "타임스텝 범위",
"UPSCALE_MODEL": "업스케일 모델",
"VAE": "VAE",
"VIDEO": "비디오",
"VOXEL": "복셀",
"WEBCAM": "웹캠"
},
@@ -364,6 +396,12 @@
"inbox": "받은 편지함",
"star": "별"
},
"importModelDialog": {
"copy": "복사",
"move": "이동",
"title": "모델 가져오기",
"type": "유형"
},
"install": {
"appDataLocationTooltip": "ComfyUI의 앱 데이터 디렉토리. 저장소:\n- 로그\n- 서버 구성",
"appPathLocationTooltip": "ComfyUI의 앱 에셋 디렉토리. ComfyUI 코드 및 에셋을 저장합니다.",
@@ -732,10 +770,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "3D 모델",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_테스트용",
"advanced": "고급",
"animation": "애니메이션",
"api": "API",
"api node": "api 노드",
"attention_experiments": "어텐션 실험",
"audio": "오디오",
"batch": "배치",
@@ -760,6 +810,7 @@
"instructpix2pix": "InstructPix2Pix",
"latent": "잠재 데이터",
"loaders": "로더",
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "마스크",
"model": "모델",
@@ -771,10 +822,12 @@
"photomaker": "포토메이커",
"postprocessing": "후처리",
"preprocessors": "전처리기",
"primitive": "프리미티브",
"samplers": "샘플러",
"sampling": "샘플링",
"schedulers": "스케줄러",
"scheduling": "스케줄링",
"sd": "sd",
"sd3": "sd3",
"sigmas": "시그마",
"stable_cascade": "Stable Cascade",
@@ -783,6 +836,10 @@
"unet": "UNet",
"upscale_diffusion": "업스케일 확산",
"upscaling": "업스케일링",
"utils": "유틸리티",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "비디오",
"video_models": "비디오 모델"
},
@@ -1330,6 +1387,7 @@
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",
"length": "{length}자여야 합니다",
"maxLength": "{length}자를 초과할 수 없습니다",
"minLength": "{length}자 이상이어야 합니다",
"password": {
@@ -1342,6 +1400,7 @@
"uppercase": "적어도 하나의 대문자를 포함해야 합니다"
},
"personalDataConsentRequired": "개인 데이터 처리에 동의해야 합니다.",
"prefix": "{prefix}(으)로 시작해야 합니다",
"required": "필수"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "Требуется вход для использования API Nodes"
},
"auth": {
"apiKey": {
"cleared": "API-ключ удалён",
"clearedDetail": "Ваш API-ключ был успешно удалён",
"description": "Используйте ваш Comfy API-ключ для активации API-узлов",
"error": "Недействительный API-ключ",
"generateKey": "Получить здесь",
"helpText": "Нужен API-ключ?",
"invalid": "Недействительный API-ключ",
"invalidDetail": "Пожалуйста, введите действительный API-ключ",
"label": "API-ключ",
"placeholder": "Введите ваш API-ключ",
"storageFailed": "Не удалось сохранить API-ключ",
"storageFailedDetail": "Пожалуйста, попробуйте еще раз.",
"stored": "API-ключ сохранён",
"storedDetail": "Ваш API-ключ был успешно сохранён",
"title": "API-ключ",
"whitelistInfo": "О не включённых в белый список сайтах"
},
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
@@ -43,6 +61,7 @@
"loginWithGithub": "Войти через Github",
"loginWithGoogle": "Войти через Google",
"newUser": "Вы здесь впервые?",
"noAssociatedUser": "С предоставленным API-ключом не связан ни один пользователь Comfy",
"orContinueWith": "Или продолжить с",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
@@ -56,6 +75,7 @@
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт",
"useApiKey": "Comfy API-ключ",
"userAvatar": "Аватар пользователя"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "Открепить"
},
"credits": {
"apiPricing": "Цены на API",
"credits": "Кредиты",
"faqs": "Часто задаваемые вопросы",
"invoiceHistory": "История счетов",
@@ -149,8 +170,10 @@
"yourCreditBalance": "Ваш баланс кредитов"
},
"dataTypes": {
"*": "*",
"AUDIO": "АУДИО",
"BOOLEAN": "БУЛЕВО",
"CAMERA_CONTROL": "УПРАВЛЕНИЕ_КАМЕРОЙ",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
@@ -167,20 +190,29 @@
"INT": "ЦЕЛОЕ",
"LATENT": "ЛАТЕНТНЫЙ",
"LATENT_OPERATION": АТЕНТНАЯ_ОПЕРАЦИЯ",
"LOAD3D_CAMERA": "ЗАГРУЗИТЬ3D_КАМЕРУ",
"LOAD_3D": "ЗАГРУЗИТЬ_3D",
"LOAD_3D_ANIMATION": "ЗАГРУЗИТЬ_3D_АНИМАЦИЮ",
"LUMA_CONCEPTS": "LUMA_CONCEPTS",
"LUMA_REF": "LUMA_REF",
"MASK": "МАСКА",
"MESH": "СЕТКА",
"MODEL": "МОДЕЛЬ",
"NOISE": "ШУМ",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": АБЛОН_PIXVERSE",
"RECRAFT_COLOR": "RECRAFT_ЦВЕТ",
"RECRAFT_CONTROLS": "RECRAFT_УПРАВЛЕНИЯ",
"RECRAFT_V3_STYLE": "RECRAFT_V3_СТИЛЬ",
"SAMPLER": "СЭМПЛЕР",
"SIGMAS": "СИГМЫ",
"STRING": "СТРОКА",
"STYLE_MODEL": "МОДЕЛЬ_СТИЛЯ",
"SVG": "SVG",
"TIMESTEPS_RANGE": "ДИАПАЗОН_ВРЕМЕННЫХАГОВ",
"UPSCALE_MODEL": "МОДЕЛЬ_АПСКЕЙЛА",
"VAE": "VAE",
"VIDEO": "ВИДЕО",
"VOXEL": "ВОКСЕЛ",
"WEBCAM": "ВЕБ-КАМЕРА"
},
@@ -364,6 +396,12 @@
"inbox": "Входящие",
"star": "Звезда"
},
"importModelDialog": {
"copy": "Копировать",
"move": "Переместить",
"title": "Импорт модели",
"type": "Тип"
},
"install": {
"appDataLocationTooltip": "Директория данных приложения ComfyUI. Хранит:\n- Логи\n- Конфигурации сервера",
"appPathLocationTooltip": "Директория активов приложения ComfyUI. Хранит код и активы ComfyUI",
@@ -732,10 +770,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "3d_модели",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_для_тестирования",
"advanced": "расширенный",
"animation": "анимация",
"api": "api",
"api node": "api-узел",
"attention_experiments": "эксперименты_внимания",
"audio": "аудио",
"batch": "пакет",
@@ -760,6 +810,7 @@
"instructpix2pix": "instructpix2pix",
"latent": "латентный",
"loaders": "загрузчики",
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "маска",
"model": "модель",
@@ -771,10 +822,12 @@
"photomaker": "photomaker",
"postprocessing": "постобработка",
"preprocessors": "предобработчики",
"primitive": "примитив",
"samplers": "семплеры",
"sampling": "выборка",
"schedulers": "schedulers",
"scheduling": "scheduling",
"sd": "sd",
"sd3": "sd3",
"sigmas": "сигмы",
"stable_cascade": "стабильная_каскадная",
@@ -783,6 +836,10 @@
"unet": "unet",
"upscale_diffusion": "диффузии_апскейла",
"upscaling": "апскейл",
"utils": "утилиты",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "видео",
"video_models": "видеомодели"
},
@@ -1330,6 +1387,7 @@
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",
"length": "Должно быть {length} символов",
"maxLength": "Должно быть не более {length} символов",
"minLength": "Должно быть не менее {length} символов",
"password": {
@@ -1342,6 +1400,7 @@
"uppercase": "Должен содержать хотя бы одну заглавную букву"
},
"personalDataConsentRequired": "Вы должны согласиться на обработку ваших персональных данных.",
"prefix": "Должно начинаться с {prefix}",
"required": "Обязательно"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
"title": "使用API节点需要登录"
},
"auth": {
"apiKey": {
"cleared": "API 密钥已清除",
"clearedDetail": "您的 API 密钥已成功清除",
"description": "使用您的 Comfy API 密钥以启用 API 节点",
"error": "无效的 API 密钥",
"generateKey": "在这里获取",
"helpText": "需要 API 密钥?",
"invalid": "无效的 API 密钥",
"invalidDetail": "请输入有效的 API 密钥",
"label": "API 密钥",
"placeholder": "请输入您的 API 密钥",
"storageFailed": "API 密钥存储失败",
"storageFailedDetail": "请重试。",
"stored": "API 密钥已存储",
"storedDetail": "您的 API 密钥已成功存储",
"title": "API 密钥",
"whitelistInfo": "关于非白名单网站"
},
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
@@ -43,6 +61,7 @@
"loginWithGithub": "使用Github登录",
"loginWithGoogle": "使用Google登录",
"newUser": "新来的?",
"noAssociatedUser": "所提供的 API 密钥未关联任何 Comfy 用户",
"orContinueWith": "或者继续使用",
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
@@ -56,6 +75,7 @@
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户",
"useApiKey": "Comfy API 密钥",
"userAvatar": "用户头像"
},
"passwordUpdate": {
@@ -131,6 +151,7 @@
"Unpin": "取消固定"
},
"credits": {
"apiPricing": "API 价格",
"credits": "积分",
"faqs": "常见问题",
"invoiceHistory": "发票历史",
@@ -149,8 +170,10 @@
"yourCreditBalance": "您的积分余额"
},
"dataTypes": {
"*": "*",
"AUDIO": "音频",
"BOOLEAN": "布尔",
"CAMERA_CONTROL": "相机控制",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP视觉",
"CLIP_VISION_OUTPUT": "CLIP视觉输出",
@@ -167,20 +190,29 @@
"INT": "整数",
"LATENT": "Latent",
"LATENT_OPERATION": "Latent操作",
"LOAD3D_CAMERA": "加载3D相机",
"LOAD_3D": "加载3D",
"LOAD_3D_ANIMATION": "加载3D动画",
"LUMA_CONCEPTS": "Luma 概念",
"LUMA_REF": "Luma 参考",
"MASK": "遮罩",
"MESH": "网格",
"MODEL": "模型",
"NOISE": "噪波",
"PHOTOMAKER": "PhotoMaker",
"PIXVERSE_TEMPLATE": "Pixverse 模板",
"RECRAFT_COLOR": "Recraft 颜色",
"RECRAFT_CONTROLS": "Recraft 控件",
"RECRAFT_V3_STYLE": "Recraft V3 风格",
"SAMPLER": "采样器",
"SIGMAS": "Sigmas",
"STRING": "字符串",
"STYLE_MODEL": "风格模型",
"SVG": "SVG",
"TIMESTEPS_RANGE": "时间间隔范围",
"UPSCALE_MODEL": "放大模型",
"VAE": "VAE",
"VIDEO": "视频",
"VOXEL": "体素",
"WEBCAM": "摄像头"
},
@@ -364,6 +396,12 @@
"inbox": "收件箱",
"star": "星星"
},
"importModelDialog": {
"copy": "复制",
"move": "移动",
"title": "导入模型",
"type": "类型"
},
"install": {
"appDataLocationTooltip": "ComfyUI 的应用数据目录。存储:\n- 日志\n- 服务器配置",
"appPathLocationTooltip": "ComfyUI 的应用资产目录。存储 ComfyUI 代码和资产",
@@ -732,10 +770,22 @@
"nodeCategories": {
"3d": "3d",
"3d_models": "3D模型",
"BFL": "BFL",
"Ideogram": "Ideogram",
"Kling": "Kling",
"Luma": "Luma",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Stability AI": "Stability AI",
"Veo": "Veo",
"_for_testing": "_用于测试",
"advanced": "高级",
"animation": "动画",
"api": "API",
"api node": "api 节点",
"attention_experiments": "注意力实验",
"audio": "音频",
"batch": "批处理",
@@ -760,6 +810,7 @@
"instructpix2pix": "InstructPix2Pix",
"latent": "Latent",
"loaders": "加载器",
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "遮罩",
"model": "模型",
@@ -771,10 +822,12 @@
"photomaker": "PhotoMaker",
"postprocessing": "后处理",
"preprocessors": "预处理器",
"primitive": "基础",
"samplers": "采样器",
"sampling": "采样",
"schedulers": "调度器",
"scheduling": "调度",
"sd": "sd",
"sd3": "SD3",
"sigmas": "Sigmas",
"stable_cascade": "StableCascade",
@@ -783,6 +836,10 @@
"unet": "U-Net",
"upscale_diffusion": "放大扩散",
"upscaling": "放大",
"utils": "工具",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "视频",
"video_models": "视频模型"
},
@@ -1330,6 +1387,7 @@
},
"validation": {
"invalidEmail": "无效的电子邮件地址",
"length": "必须为{length}个字符",
"maxLength": "不能超过{length}个字符",
"minLength": "必须至少有{length}个字符",
"password": {
@@ -1342,6 +1400,7 @@
"uppercase": "必须包含至少一个大写字母"
},
"personalDataConsentRequired": "您必须同意处理您的个人数据。",
"prefix": "必须以 {prefix} 开头",
"required": "必填"
},
"welcome": {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,16 @@ import { z } from 'zod'
import { t } from '@/i18n'
export const apiKeySchema = z.object({
apiKey: z
.string()
.trim()
.startsWith('comfyui-', t('validation.prefix', { prefix: 'comfyui-' }))
.length(72, t('validation.length', { length: 72 }))
})
export type ApiKeyData = z.infer<typeof apiKeySchema>
export const signInSchema = z.object({
email: z
.string()

View File

@@ -58,6 +58,21 @@ interface QueuePromptRequestBody {
* ```
*/
auth_token_comfy_org?: string
/**
* The auth token for the comfy org account if the user is logged in.
*
* Backend node can access this token by specifying following input:
* ```python
* def INPUT_TYPES(s):
* return {
* "hidden": { "api_key": "API_KEY_COMFY_ORG" }
* }
*
* def execute(self, api_key: str):
* print(f"API Key: {api_key}")
* ```
*/
api_key_comfy_org?: string
}
front?: boolean
number?: number
@@ -67,6 +82,7 @@ interface QueuePromptRequestBody {
interface FrontendApiCalls {
graphChanged: ComfyWorkflowJSON
promptQueued: { number: number; batchCount: number }
unhandledFileDrop: { file: File }
graphCleared: never
reconnecting: never
reconnected: never
@@ -228,6 +244,10 @@ export class ComfyApi extends EventTarget {
* custom nodes are patched.
*/
authToken?: string
/**
* The API key for the comfy org account if the user logged in via API key.
*/
apiKey?: string
constructor() {
super()
@@ -294,20 +314,23 @@ export class ComfyApi extends EventTarget {
* Provides type safety for the contravariance issue with EventTarget (last checked TS 5.6).
* @param type The type of event to emit
* @param detail The detail property used for a custom event ({@link CustomEventInit.detail})
* @param init The event config used for a custom event ({@link CustomEventInit})
*/
dispatchCustomEvent<T extends SimpleApiEvents>(type: T): boolean
dispatchCustomEvent<T extends ComplexApiEvents>(
type: T,
detail: ApiEventTypes[T] | null
detail: ApiEventTypes[T] | null,
init?: EventInit
): boolean
dispatchCustomEvent<T extends keyof ApiEventTypes>(
type: T,
detail?: ApiEventTypes[T]
detail?: ApiEventTypes[T],
init?: EventInit
): boolean {
const event =
detail === undefined
? new CustomEvent(type)
: new CustomEvent(type, { detail })
? new CustomEvent(type, { ...init })
: new CustomEvent(type, { detail, ...init })
return super.dispatchEvent(event)
}
@@ -545,6 +568,7 @@ export class ComfyApi extends EventTarget {
prompt,
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
extra_pnginfo: { workflow }
}
}

View File

@@ -29,10 +29,14 @@ import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
@@ -64,7 +68,6 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import { getSvgMetadata } from './metadata/svg'
import {
getFlacMetadata,
getLatentMetadata,
@@ -1062,20 +1065,21 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {
// @note: Set view after the graph has been rendered once. fitView uses
// boundingRect on nodes to calculate the view bounds, which only become
// available after the first render.
requestAnimationFrame(() => {
useLitegraphService().fitView()
})
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {
// @note: Set view after the graph has been rendered once. fitView uses
// boundingRect on nodes to calculate the view bounds, which only become
// available after the first render.
requestAnimationFrame(() => {
useLitegraphService().fitView()
})
}
}
} catch (error) {
useDialogService().showErrorDialog(error, {
@@ -1183,6 +1187,7 @@ export class ComfyApp {
let comfyOrgAuthToken =
(await useFirebaseAuthStore().getIdToken()) ?? undefined
let comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {
while (this.#queueItems.length) {
@@ -1196,8 +1201,10 @@ export class ComfyApp {
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
api.authToken = comfyOrgAuthToken
api.apiKey = comfyOrgApiKey ?? undefined
const res = await api.queuePrompt(number, p)
delete api.authToken
delete api.apiKey
executionStore.lastNodeErrors = res.node_errors ?? null
if (executionStore.lastNodeErrors?.length) {
this.canvas.draw(true, true)
@@ -1245,10 +1252,22 @@ export class ComfyApp {
return !executionStore.lastNodeErrors
}
showErrorOnFileLoad(file: File) {
useToastStore().addAlert(
t('toastMessages.fileLoadError', { fileName: file.name })
onUnhandledFile(file: File) {
// Fire custom event to allow other parts of the app to handle the file
const unhandled = api.dispatchCustomEvent(
'unhandledFileDrop',
{ file },
{
cancelable: true
}
)
if (unhandled) {
// Nothing handled the event, so show the error dialog
useToastStore().addAlert(
t('toastMessages.fileLoadError', { fileName: file.name })
)
}
}
/**
@@ -1284,7 +1303,7 @@ export class ComfyApp {
this.graph.serialize() as unknown as ComfyWorkflowJSON
)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
@@ -1297,7 +1316,25 @@ export class ComfyApp {
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.onUnhandledFile(file)
}
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.onUnhandledFile(file)
}
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
const pngInfo = await getFlacMetadata(file)
@@ -1309,7 +1346,7 @@ export class ComfyApp {
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
@@ -1318,7 +1355,7 @@ export class ComfyApp {
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (
file.type === 'video/mp4' ||
@@ -1341,7 +1378,7 @@ export class ComfyApp {
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (
file.type === 'model/gltf-binary' ||
@@ -1353,7 +1390,7 @@ export class ComfyApp {
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else if (
file.type === 'application/json' ||
@@ -1384,7 +1421,7 @@ export class ComfyApp {
const info = await getLatentMetadata(file)
// TODO define schema to LatentMetadata
// @ts-expect-error
if (info.workflow) {
if (info?.workflow) {
await this.loadGraphData(
// @ts-expect-error
JSON.parse(info.workflow),
@@ -1393,14 +1430,14 @@ export class ComfyApp {
fileName
)
// @ts-expect-error
} else if (info.prompt) {
} else if (info?.prompt) {
// @ts-expect-error
this.loadApiJson(JSON.parse(info.prompt))
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
} else {
this.showErrorOnFileLoad(file)
this.onUnhandledFile(file)
}
}

View File

@@ -152,7 +152,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
this.options.onDraw?.(this)
}
onRemove(): void {
override onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
}
@@ -175,7 +175,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
}
/** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) {
override computeLayoutSize(node: LGraphNode) {
if (this.type === 'hidden') {
return {
minHeight: 0,
@@ -239,7 +239,7 @@ export class ComponentWidgetImpl<V extends object | string>
this.inputSpec = obj.inputSpec
}
computeLayoutSize() {
override computeLayoutSize() {
const minHeight = this.options.getMinHeight?.() ?? 50
const maxHeight = this.options.getMaxHeight?.()
return {

View File

@@ -0,0 +1,29 @@
export async function getMp3Metadata(file: File) {
const reader = new FileReader()
const read_process = new Promise(
(r) => (reader.onload = (event) => r(event?.target?.result))
)
reader.readAsArrayBuffer(file)
const arrayBuffer = (await read_process) as ArrayBuffer
//https://stackoverflow.com/questions/7302439/how-can-i-determine-that-a-particular-file-is-in-fact-an-mp3-file#7302482
const sig_bytes = new Uint8Array(arrayBuffer, 0, 3)
if (
(sig_bytes[0] != 0xff && sig_bytes[1] != 0xfb) ||
(sig_bytes[0] != 0x49 && sig_bytes[1] != 0x44 && sig_bytes[2] != 0x33)
)
console.error('Invalid file signature.')
let header = ''
while (header.length < arrayBuffer.byteLength) {
const page = String.fromCharCode(
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
)
header += page
if (page.match('\u00ff\u00fb')) break
}
let workflow, prompt
let prompt_s = header.match(/prompt\u0000(\{.*?\})\u0000/s)?.[1]
if (prompt_s) prompt = JSON.parse(prompt_s)
let workflow_s = header.match(/workflow\u0000(\{.*?\})\u0000/s)?.[1]
if (workflow_s) workflow = JSON.parse(workflow_s)
return { prompt, workflow }
}

View File

@@ -0,0 +1,30 @@
export async function getOggMetadata(file: File) {
const reader = new FileReader()
const read_process = new Promise(
(r) => (reader.onload = (event) => r(event?.target?.result))
)
reader.readAsArrayBuffer(file)
const arrayBuffer = (await read_process) as ArrayBuffer
const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4))
if (signature !== 'OggS') console.error('Invalid file signature.')
let oggs = 0
let header = ''
while (header.length < arrayBuffer.byteLength) {
const page = String.fromCharCode(
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
)
if (page.match('OggS\u0000')) oggs++
header += page
if (oggs > 1) break
}
let workflow, prompt
let prompt_s = header
.match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (prompt_s) prompt = JSON.parse(prompt_s)
let workflow_s = header
.match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (workflow_s) workflow = JSON.parse(workflow_s)
return { prompt, workflow }
}

View File

@@ -143,12 +143,17 @@ export function getLatentMetadata(file) {
const dataView = new DataView(safetensorsData.buffer)
let header_size = dataView.getUint32(0, true)
let offset = 8
let header = JSON.parse(
new TextDecoder().decode(
safetensorsData.slice(offset, offset + header_size)
try {
let header = JSON.parse(
new TextDecoder().decode(
safetensorsData.slice(offset, offset + header_size)
)
)
)
r(header.__metadata__)
r(header.__metadata__)
} catch (e) {
// Invalid header
r(undefined)
}
}
var slice = file.slice(0, 1024 * 1024 * 4)

View File

@@ -2,6 +2,7 @@ import ApiNodesNewsContent from '@/components/dialog/content/ApiNodesNewsContent
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import ImportModelDialogContent from '@/components/dialog/content/ImportModelDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
@@ -406,6 +407,16 @@ export const useDialogService = () => {
})
}
function showImportModelDialog(
props: InstanceType<typeof ImportModelDialogContent>['$props']
) {
dialogStore.showDialog({
key: 'global-import-model',
component: ImportModelDialogContent,
props
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -422,6 +433,7 @@ export const useDialogService = () => {
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showApiNodesNewsDialog,
showImportModelDialog,
prompt,
confirm
}

View File

@@ -36,6 +36,21 @@ export const useWorkflowService = () => {
}
return defaultName
}
/**
* Adds scale and offset from litegraph canvas to the workflow JSON.
* @param workflow The workflow to add the view restore data to
*/
function addViewRestore(workflow: ComfyWorkflowJSON) {
if (!settingStore.get('Comfy.EnableWorkflowViewRestore')) return
const { offset, scale } = app.canvas.ds
const [x, y] = offset
workflow.extra ??= {}
workflow.extra.ds = { scale, offset: [x, y] }
}
/**
* Export the current workflow as a JSON file
* @param filename The filename to save the workflow as
@@ -50,6 +65,8 @@ export const useWorkflowService = () => {
filename = workflow.filename
}
const p = await app.graphToPrompt()
addViewRestore(p.workflow)
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = await getFilename(filename)

View File

@@ -0,0 +1,110 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useToastStore } from '@/stores/toastStore'
import { ApiKeyAuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
type ComfyApiUser =
operations['createCustomer']['responses']['201']['content']['application/json']
const STORAGE_KEY = 'comfy_api_key'
export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const firebaseAuthStore = useFirebaseAuthStore()
const apiKey = useLocalStorage<string | null>(STORAGE_KEY, null)
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
const currentUser = ref<ComfyApiUser | null>(null)
const isAuthenticated = computed(() => !!currentUser.value)
const initializeUserFromApiKey = async () => {
const createCustomerResponse = await firebaseAuthStore.createCustomer()
if (!createCustomerResponse) {
apiKey.value = null
throw new Error(t('auth.login.noAssociatedUser'))
}
currentUser.value = createCustomerResponse
}
watch(
apiKey,
() => {
if (apiKey.value) {
// IF API key is set, initialize user
void initializeUserFromApiKey()
} else {
// IF API key is cleared, clear user
currentUser.value = null
}
},
{ immediate: true }
)
const reportError = (error: unknown) => {
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
toastStore.add({
severity: 'error',
summary: t('auth.apiKey.storageFailed'),
detail: t('auth.apiKey.storageFailedDetail')
})
} else {
toastErrorHandler(error)
}
}
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
apiKey.value = newApiKey
toastStore.add({
severity: 'success',
summary: t('auth.apiKey.stored'),
detail: t('auth.apiKey.storedDetail'),
life: 5000
})
return true
}, reportError)
const clearStoredApiKey = wrapWithErrorHandlingAsync(async () => {
apiKey.value = null
toastStore.add({
severity: 'success',
summary: t('auth.apiKey.cleared'),
detail: t('auth.apiKey.clearedDetail'),
life: 5000
})
return true
}, reportError)
const getApiKey = () => apiKey.value
/**
* Retrieves the appropriate authentication header for API requests if an
* API key is available, otherwise returns null.
*/
const getAuthHeader = (): ApiKeyAuthHeader | null => {
const comfyOrgApiKey = getApiKey()
if (comfyOrgApiKey) {
return {
'X-API-KEY': comfyOrgApiKey
}
}
return null
}
return {
// State
currentUser,
isAuthenticated,
// Actions
storeApiKey,
clearStoredApiKey,
getAuthHeader,
getApiKey
}
})

View File

@@ -20,6 +20,8 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
type CreditPurchaseResponse =
@@ -93,12 +95,35 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
/**
* Retrieves the appropriate authentication header for API requests.
* Checks for authentication in the following order:
* 1. Firebase authentication token (if user is logged in)
* 2. API key (if stored in the browser's credential manager)
*
* @returns {Promise<AuthHeader | null>}
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
* - null if neither authentication method is available
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
// If available, set header with JWT used to identify the user to Firebase service
const token = await getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
// If not authenticated with Firebase, try falling back to API key if available
return useApiKeyAuthStore().getAuthHeader()
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
isFetchingBalance.value = true
try {
const token = await getIdToken()
if (!token) {
isFetchingBalance.value = false
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
@@ -106,7 +131,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
headers: {
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
}
})
@@ -133,14 +159,17 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
}
const createCustomer = async (
token: string
): Promise<CreateCustomerResponse> => {
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
}
})
if (!createCustomerRes.ok) {
@@ -181,7 +210,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!token) {
throw new Error('Cannot create customer: User not authenticated')
}
await createCustomer(token)
await createCustomer()
}
return result
@@ -242,22 +271,22 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
const token = await getIdToken()
if (!token) {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
if (!customerCreated.value) {
await createCustomer(token)
await createCustomer()
customerCreated.value = true
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
})
@@ -282,16 +311,16 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
): Promise<AccessBillingPortalResponse> => {
const token = await getIdToken()
if (!token) {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
@@ -328,6 +357,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
login,
register,
logout,
createCustomer,
getIdToken,
loginWithGoogle,
loginWithGithub,
@@ -335,6 +365,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
fetchBalance,
accessBillingPortal,
sendPasswordReset,
updatePassword: _updatePassword
updatePassword: _updatePassword,
getAuthHeader
}
})

9
src/types/authTypes.ts Normal file
View File

@@ -0,0 +1,9 @@
type LoggedInAuthHeader = {
Authorization: `Bearer ${string}`
}
export type ApiKeyAuthHeader = {
'X-API-KEY': string
}
export type AuthHeader = LoggedInAuthHeader | ApiKeyAuthHeader

View File

@@ -32,15 +32,15 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
}
interface IBaseWidget {
onRemove?: () => void
beforeQueued?: () => unknown
afterQueued?: () => unknown
onRemove?(): void
beforeQueued?(): unknown
afterQueued?(): unknown
serializeValue?(node: LGraphNode, index: number): Promise<unknown> | unknown
/**
* Refreshes the widget's value or options from its remote source.
*/
refresh?: () => unknown
refresh?(): unknown
/**
* If the widget supports dynamic prompts, this will be set to true.
@@ -54,6 +54,8 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
* ComfyUI extensions of litegraph
*/
declare module '@comfyorg/litegraph' {
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
type?: string
comfyClass: string
@@ -63,13 +65,9 @@ declare module '@comfyorg/litegraph' {
new (): T
}
interface TextWidget {
dynamicPrompts?: boolean
}
interface BaseWidget {
serializeValue?(node: LGraphNode, index: number): Promise<unknown> | unknown
}
// Add interface augmentations into the class itself
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface BaseWidget extends IBaseWidget {}
interface LGraphNode {
constructor: LGraphNodeConstructor

View File

@@ -0,0 +1,93 @@
export interface ModelSpec {
'modelspec.sai_model_spec': string
'modelspec.architecture': string
'modelspec.title': string
'modelspec.description': string
}
const architectureToType: Record<string, string> = {
'stable-diffusion-v1': 'checkpoints',
'stable-diffusion-xl-v1-base': 'checkpoints',
'Flux.1-schnell': 'checkpoints',
'Flux.1-dev': 'checkpoints',
'Flux.1-AE': 'vae'
}
interface SafetensorsHeader<TMetadata = Record<string, string> | ModelSpec> {
[k: string]: unknown
__metadata__?: TMetadata
}
export async function guessModelType(file: File): Promise<string | null> {
const header = await getHeader(file)
if (!header) return null
let suggestedType: string | null
if (isModelSpec(header)) {
suggestedType = guessFromModelSpec(header)
}
suggestedType ??= guessFromHeaderKeys(header)
return suggestedType
}
async function getHeader(file: File): Promise<SafetensorsHeader | null> {
try {
// 8 bytes: an unsigned little-endian 64-bit integer, containing the size of the header
// Slice the first 8 bytes so we don't read the whole file
const headerSizeBlob = file.slice(0, 8)
const headerSizeView = new DataView(await headerSizeBlob.arrayBuffer())
const headerSize = headerSizeView.getBigUint64(0, true)
if (
headerSize < 0 ||
headerSize > file.size ||
headerSize > Number.MAX_SAFE_INTEGER
) {
// Invalid header, probably not a safetensors file
console.log(`Invalid header size ${headerSize} for file '${file.name}'`)
return null
}
// N bytes: a JSON UTF-8 string representing the header.
const header = file.slice(8, Number(headerSize) + 8)
const content = await header.text()
return JSON.parse(content)
} catch (error) {
// Error reading the file, probably not a safetensors file
console.error(`Error reading safetensors header '${file.name}'`, error)
return null
}
}
function guessFromModelSpec(header: SafetensorsHeader<ModelSpec>) {
const architecture = header.__metadata__?.['modelspec.architecture']
if (!architecture) return null
let suggestedType = architectureToType[architecture]
if (!suggestedType) {
if (architecture?.endsWith('/lora')) {
suggestedType = 'loras'
}
}
return suggestedType
}
function guessFromHeaderKeys(header: SafetensorsHeader) {
let suggestedType: string | null = null
const keys = Object.keys(header)
if (keys.find((k) => k.startsWith('lora_unet_'))) {
suggestedType = 'loras'
} else if (keys.find((k) => k.startsWith('model.diffusion_model.'))) {
suggestedType = 'checkpoints'
}
return suggestedType
}
function isModelSpec(
header: SafetensorsHeader
): header is SafetensorsHeader<ModelSpec> {
return !!header.__metadata__?.['modelspec.sai_model_spec']
}