Compare commits

..

23 Commits

Author SHA1 Message Date
Comfy Org PR Bot
fbc6665ff4 1.17.2 (#3513)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-18 22:26:07 -04:00
Christian Byrne
2daa51421c Remove fetch-templates script (#3500) 2025-04-18 20:34:03 -04:00
Christian Byrne
0f175c3dc1 [Api Node] Add ToS and privacy policy links (#3511) 2025-04-18 20:21:20 -04:00
Christian Byrne
8d4263c94e Use dev firebase and switch to prod in release workflow (#3499) 2025-04-18 17:23:10 -04:00
Chenlei Hu
04580ac031 [SettingUI] Group setting menu items (#3510) 2025-04-18 16:47:32 -04:00
Chenlei Hu
cd35f1d86d [Refactor] Generate DOM widget id in constructor (#3508) 2025-04-18 13:47:16 -04:00
Chenlei Hu
5d584577fe [Bug] Fix uuid generation in insecure context (#3505) 2025-04-18 11:43:25 -04:00
filtered
10a96d1af6 [TMP] Temporarily disable hidream template test (#3502) 2025-04-18 21:34:02 +10:00
Comfy Org PR Bot
03392a3cc7 1.17.1 (#3486)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-17 22:57:57 -04:00
Christian Byrne
12576243ad Remove unused tailwind classes (#3495) 2025-04-17 22:57:47 -04:00
Christian Byrne
e2a6dc2ec8 [Templates] Use fallbacks when translating template titles and category names (#3494) 2025-04-17 22:57:03 -04:00
Chenlei Hu
2f77d74891 [Refactor] Move tree logic to setting composable (#3491) 2025-04-17 16:34:42 -04:00
Chenlei Hu
dacb59f5d3 [Refactor] Extract setting dialog logic into composables (#3490) 2025-04-17 15:55:16 -04:00
Christian Byrne
74f991ec1b Translate Wan2.1-Fun template titles (#3489)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 15:55:08 -04:00
Christian Byrne
6bc03a624e Add HiDream templates translations (#3485)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 13:46:11 -04:00
Christian Byrne
1fb015e046 Remove templates build from release process (#3481) 2025-04-17 10:41:36 -04:00
Christian Byrne
87bf2310b6 Support previewing animated image uploads (#3479)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 10:20:09 -04:00
filtered
f1a25989d7 Fix corrupt workflow permanently corrupts session (#3484) 2025-04-17 10:18:14 -04:00
filtered
236e3fb3e9 Log errors in generic error handler (#3482) 2025-04-17 15:16:05 +10:00
Chenlei Hu
50382827bc [API Nodes] Add auth_token_comfy_org to queue prompt request body (#3477) 2025-04-16 15:26:25 -04:00
Christian Byrne
41675805b6 [Test] Add LGraphBadge light theme browser test (#3475)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-16 15:10:14 -04:00
Makki Shizu
6321fae6f3 fix 2_pass_pose_worship zh translation (#3474) 2025-04-16 11:04:28 -04:00
Chenlei Hu
06caa21a4d [API Nodes] Setup Google/Github login (#3471) 2025-04-15 20:56:18 -04:00
60 changed files with 942 additions and 425 deletions

View File

@@ -29,9 +29,9 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
USE_PROD_FIREBASE_CONFIG: 'true'
run: |
npm ci
npm run fetch-templates
npm run build
npm run zipdist
- name: Upload dist artifact

View File

@@ -30,7 +30,7 @@ jobs:
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: '49c8220be49120dbaff85f32813d854d6dff2d05'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
- uses: actions/setup-node@v4
with:
@@ -39,7 +39,6 @@ jobs:
- name: Build ComfyUI_frontend
run: |
npm ci
npm run fetch-templates
npm run build
working-directory: ComfyUI_frontend

View File

@@ -10,7 +10,7 @@ module.exports = defineConfig({
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora.
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
`

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,11 @@
{
"8": {
"inputs": {
"image": "animated_web.webp"
},
"class_type": "DevToolsLoadAnimatedImageTest",
"_meta": {
"title": "Load Animated Image"
}
}
}

View File

@@ -0,0 +1,60 @@
{
"id": "3f1fcbf9-f9de-4935-8fad-401813f61b13",
"revision": 0,
"last_node_id": 10,
"last_link_id": 4,
"nodes": [
{
"id": 9,
"type": "SaveAnimatedWEBP",
"pos": [336, 104],
"size": [210, 368],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", 6, true, 80, "default"]
},
{
"id": 10,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [64, 104],
"size": [210, 316],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": ["animated_web.webp", "image"]
}
],
"links": [[4, 10, 0, 9, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0"
},
"version": 0.4
}

View File

@@ -92,4 +92,20 @@ test.describe('Node badge color', () => {
'node-badge-unknown-color-palette.png'
)
})
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -1,8 +1,17 @@
import { expect } from '@playwright/test'
import fs from 'fs'
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function checkTemplateFileExists(
page: Page,
filename: string
): Promise<boolean> {
const response = await page.request.head(
new URL(`/templates/${filename}`, page.url()).toString()
)
return response.ok()
}
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
@@ -12,32 +21,32 @@ test.describe('Templates', () => {
test('should have a JSON workflow file for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const workflowPath = comfyPage.templates.getTemplatePath(
const exists = await checkTemplateFileExists(
comfyPage.page,
`${template.name}.json`
)
expect(
fs.existsSync(workflowPath),
`Missing workflow: ${template.name}`
).toBe(true)
expect(exists, `Missing workflow: ${template.name}`).toBe(true)
}
})
test('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const { name, mediaSubtype, thumbnailVariant } = template
const baseMedia = `${name}-1.${mediaSubtype}`
const basePath = comfyPage.templates.getTemplatePath(baseMedia)
// Check base thumbnail
expect(
fs.existsSync(basePath),
`Missing base thumbnail: ${baseMedia}`
).toBe(true)
const baseExists = await checkTemplateFileExists(
comfyPage.page,
baseMedia
)
expect(baseExists, `Missing base thumbnail: ${baseMedia}`).toBe(true)
// Check second thumbnail for variants that need it
if (
@@ -45,9 +54,12 @@ test.describe('Templates', () => {
thumbnailVariant === 'hoverDissolve'
) {
const secondMedia = `${name}-2.${mediaSubtype}`
const secondPath = comfyPage.templates.getTemplatePath(secondMedia)
const secondExists = await checkTemplateFileExists(
comfyPage.page,
secondMedia
)
expect(
fs.existsSync(secondPath),
secondExists,
`Missing second thumbnail: ${secondMedia} required for ${thumbnailVariant}`
).toBe(true)
}
@@ -86,4 +98,48 @@ test.describe('Templates', () => {
// Expect the templates dialog to be shown
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
// Expect the title to be used as fallback for template cards
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
})

View File

@@ -186,6 +186,105 @@ test.describe('Image widget', () => {
})
})
test.describe('Animated image widget', () => {
test('Shows preview of uploaded animated image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node
const loadNodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = loadNodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
await comfyPage.nextFrame()
// Get the SaveAnimatedWEBP node
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
const saveAnimatedWebpNode = saveNodes[0]
if (!saveAnimatedWebpNode)
throw new Error('SaveAnimatedWEBP node not found')
// Simulate the graph executing
await comfyPage.page.evaluate(
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the SaveAnimatedWEBP node to have an output preview
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_saved_webp.png'
)
})
})
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

1
global.d.ts vendored
View File

@@ -3,6 +3,7 @@ declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_FIREBASE_CONFIG__: boolean
interface Navigator {
/**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.17.0",
"version": "1.17.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.17.0",
"version": "1.17.2",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.17.0",
"version": "1.17.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -25,8 +25,7 @@
"lint:fix": "eslint src --fix",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"fetch-templates": "tsx scripts/fetch-templates.ts"
"json-schema": "tsx scripts/generate-json-schema.ts"
},
"devDependencies": {
"@eslint/js": "^9.8.0",

View File

@@ -1,24 +0,0 @@
import fs from 'fs-extra'
import { execSync } from 'node:child_process'
import path from 'node:path'
const workflowTemplatesRepo = 'https://github.com/Comfy-Org/workflow_templates'
const tempRepoDir = './templates_repo'
// Clone the repository
execSync(`git clone ${workflowTemplatesRepo} --depth 1 ${tempRepoDir}`)
// Create public/templates directory if it doesn't exist
fs.ensureDirSync('public/templates')
// Copy templates from repo to public/templates
const sourceDir = path.join(tempRepoDir, 'templates')
const targetDir = 'public/templates'
// Copy entire directory at once
fs.copySync(sourceDir, targetDir)
// Remove the temporary repository directory
fs.removeSync(tempRepoDir)
console.log('Templates fetched successfully')

View File

@@ -10,15 +10,21 @@
/>
<Listbox
v-model="activeCategory"
:options="categories"
:options="groupedMenuTreeNodes"
option-label="translatedLabel"
option-group-label="label"
option-group-children="children"
scroll-height="100%"
:option-disabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="border-none w-full"
/>
>
<template #optiongroup>
<Divider class="my-0" />
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4 hidden md:flex" />
<Divider layout="horizontal" class="flex md:hidden" />
@@ -73,19 +79,13 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, defineAsyncComponent, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { st } from '@/i18n'
import {
SettingTreeNode,
getSettingInfo,
useSettingStore
} from '@/stores/settingStore'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { useSettingUI } from '@/composables/setting/useSettingUI'
import { SettingTreeNode } from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
@@ -95,7 +95,7 @@ import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import SettingsPanel from './setting/SettingsPanel.vue'
const props = defineProps<{
const { defaultPanel } = defineProps<{
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
}>()
@@ -109,71 +109,23 @@ const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
children: []
}
const {
activeCategory,
defaultCategory,
settingCategories,
groupedMenuTreeNodes
} = useSettingUI(defaultPanel)
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const extensionPanelNode: SettingTreeNode = {
key: 'extension',
label: 'Extension',
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
/**
* Server config panel is only available in Electron. We might want to support
* it in the web version in the future.
*/
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
return isElectron() ? [serverConfigPanelNode] : []
})
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const { t } = useI18n()
const categories = computed<SettingTreeNode[]>(() =>
[
...settingCategories.value,
keybindingPanelNode,
extensionPanelNode,
...serverConfigPanelNodeList.value,
aboutPanelNode
].map((node) => ({
...node,
translatedLabel: t(
`settingsCategories.${normalizeI18nKey(node.label)}`,
node.label
)
}))
)
const activeCategory = ref<SettingTreeNode | null>(null)
const getDefaultCategory = () => {
return props.defaultPanel
? categories.value.find((x) => x.key === props.defaultPanel) ??
categories.value[0]
: categories.value[0]
}
onMounted(() => {
activeCategory.value = getDefaultCategory()
})
const {
searchQuery,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
@@ -183,92 +135,20 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
}))
}
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
const searchResults = computed<ISettingGroup[]>(() => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
if (
activeCategory.value === null ||
activeCategory.value.label === info.category
) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
})
/**
* Settings categories that contains at least one setting in search results.
*/
const searchResultsCategories = computed<Set<string>>(() => {
return new Set(
filteredSettingIds.value.map(
(id) => getSettingInfo(settingStore.settingsById[id]).category
)
)
})
const handleSearch = (query: string) => {
if (!query) {
filteredSettingIds.value = []
activeCategory.value ??= getDefaultCategory()
return
}
const queryLower = query.toLocaleLowerCase()
const allSettings = flattenTree<SettingParams>(settingRoot.value)
const filteredSettings = allSettings.filter((setting) => {
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(
`settings.${normalizeI18nKey(setting.id)}.name`,
setting.name
).toLocaleLowerCase()
const info = getSettingInfo(setting)
const translatedCategory = st(
`settingsCategories.${normalizeI18nKey(info.category)}`,
info.category
).toLocaleLowerCase()
const translatedSubCategory = st(
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
info.subCategory
).toLocaleLowerCase()
return (
idLower.includes(queryLower) ||
nameLower.includes(queryLower) ||
translatedName.includes(queryLower) ||
translatedCategory.includes(queryLower) ||
translatedSubCategory.includes(queryLower)
)
})
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
activeCategory.value = null
handleSearchBase(query)
activeCategory.value = query ? null : defaultCategory.value
}
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
// Get search results
const searchResults = computed<ISettingGroup[]>(() =>
getSearchResults(activeCategory.value)
)
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
@@ -313,14 +193,8 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Show a separator line above the Keybinding tab */
/* This indicates the start of custom setting panels */
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) {
position: relative;
}
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before {
@apply content-[''] top-0 left-0 absolute w-full;
border-top: 1px solid var(--p-divider-border-color);
/* Hide the first group separator */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}
</style>

View File

@@ -61,14 +61,22 @@
<!-- Terms -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.termsLink')
}}</span>
<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') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.privacyLink')
}}</span
>.
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }}
</a>
.
</p>
</div>
</template>
@@ -79,6 +87,7 @@ import Divider from 'primevue/divider'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -92,33 +101,32 @@ const { onSuccess } = defineProps<{
}>()
const firebaseAuthStore = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isSignIn = ref(true)
const toggleState = () => {
isSignIn.value = !isSignIn.value
}
const signInWithGoogle = () => {
// Implement Google login
console.log(isSignIn.value)
console.log('Google login clicked')
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGoogle()
onSuccess()
}
})
const signInWithGithub = () => {
// Implement Github login
console.log(isSignIn.value)
console.log('Github login clicked')
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGithub()
onSuccess()
}
})
const signInWithEmail = async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
}
onSuccess()
}
onSuccess()
}
)
</script>

View File

@@ -74,9 +74,7 @@
</div>
</div>
</div>
<div
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel

View File

@@ -16,7 +16,7 @@
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
class="px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>

View File

@@ -4,7 +4,7 @@
<div
v-for="nodeDef in mappedNodeDefs"
:key="createNodeDefKey(nodeDef)"
class="border border-surface-border rounded-lg p-4"
class="border rounded-lg p-4"
>
<NodePreview :node-def="nodeDef" class="!text-[.625rem] !min-w-full" />
</div>

View File

@@ -10,9 +10,7 @@
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>

View File

@@ -52,21 +52,15 @@
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1">
<h3
class="line-clamp-1 text-lg font-normal text-surface-900 dark:text-surface-100"
:title="title"
>
<h3 class="line-clamp-1 text-lg font-normal" :title="title">
{{ title }}
</h3>
<p
class="line-clamp-2 text-sm text-surface-600 dark:text text-muted"
:title="description"
>
<p class="line-clamp-2 text-sm text-muted" :title="description">
{{ description }}
</p>
</div>
<div
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full bg-surface-100"
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full"
>
<i class="pi pi-angle-right text-2xl" />
</div>
@@ -123,12 +117,13 @@ const overlayThumbnailSrc = computed(() =>
)
const title = computed(() => {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? st(
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.name
fallback
)
: template.name ?? `${sourceModule} Template`
: fallback
})
const description = computed(() => template.description.replace(/[-_]/g, ' '))

View File

@@ -10,11 +10,8 @@
>
<slot />
</div>
<div
v-else
class="w-full h-full flex items-center justify-center bg-surface-card"
>
<i class="pi pi-file text-4xl text-surface-600" />
<div v-else class="w-full h-full flex items-center justify-center">
<i class="pi pi-file text-4xl" />
</div>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
import { createImageHost } from '@/scripts/ui/imagePreview'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
/**
* Composable for handling animated image previews in nodes
@@ -42,6 +43,16 @@ export function useNodeAnimatedImage() {
widget.serialize = false
widget.serializeValue = () => undefined
widget.options.host.updateImages(node.imgs)
widget.computeLayoutSize = () => {
const img = widget.options.host.getCurrentImage()
if (!img) return { minHeight: 0, minWidth: 0 }
return fitDimensionsToNodeWidth(
img.naturalWidth,
img.naturalHeight,
node.size?.[0] || 0
)
}
}
}

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
const VIDEO_WIDGET_NAME = 'video-preview'
const VIDEO_DEFAULT_OPTIONS = {
@@ -131,12 +132,15 @@ export const useNodeVideo = (node: LGraphNode) => {
let minWidth = DEFAULT_VIDEO_SIZE
const setMinDimensions = (video: HTMLVideoElement) => {
const intrinsicAspectRatio = video.videoWidth / video.videoHeight
if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio)) return
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
fitDimensionsToNodeWidth(
video.videoWidth,
video.videoHeight,
node.size?.[0] || DEFAULT_VIDEO_SIZE
)
// Set min. height s.t. video spans node's x-axis while maintaining aspect ratio
minWidth = node.size?.[0] || DEFAULT_VIDEO_SIZE
minHeight = Math.max(minWidth / intrinsicAspectRatio, 64)
minWidth = calculatedWidth
minHeight = calculatedHeight
}
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>

View File

@@ -0,0 +1,122 @@
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import {
SettingTreeNode,
getSettingInfo,
useSettingStore
} from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
export function useSettingSearch() {
const settingStore = useSettingStore()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
/**
* Settings categories that contains at least one setting in search results.
*/
const searchResultsCategories = computed<Set<string>>(() => {
return new Set(
filteredSettingIds.value.map(
(id) => getSettingInfo(settingStore.settingsById[id]).category
)
)
})
/**
* Check if the search query is empty
*/
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
/**
* Check if we're in search mode
*/
const inSearch = computed(
() => !queryIsEmpty.value && !searchInProgress.value
)
/**
* Handle search functionality
*/
const handleSearch = (query: string) => {
if (!query) {
filteredSettingIds.value = []
return
}
const queryLower = query.toLocaleLowerCase()
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(
`settings.${normalizeI18nKey(setting.id)}.name`,
setting.name
).toLocaleLowerCase()
const info = getSettingInfo(setting)
const translatedCategory = st(
`settingsCategories.${normalizeI18nKey(info.category)}`,
info.category
).toLocaleLowerCase()
const translatedSubCategory = st(
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
info.subCategory
).toLocaleLowerCase()
return (
idLower.includes(queryLower) ||
nameLower.includes(queryLower) ||
translatedName.includes(queryLower) ||
translatedCategory.includes(queryLower) ||
translatedSubCategory.includes(queryLower)
)
})
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
}
/**
* Get search results grouped by category
*/
const getSearchResults = (
activeCategory: SettingTreeNode | null
): ISettingGroup[] => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
if (activeCategory === null || activeCategory.label === info.category) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
}
return {
searchQuery,
filteredSettingIds,
searchInProgress,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch,
getSearchResults
}
}

View File

@@ -0,0 +1,123 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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'
export function useSettingUI(
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
) {
const { t } = useI18n()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
(setting: SettingParams) => setting.type !== 'hidden'
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)
const floatingSettings = (root.children ?? []).filter((node) => node.leaf)
if (floatingSettings.length) {
root.children = (root.children ?? []).filter((node) => !node.leaf)
root.children.push({
key: 'Other',
label: 'Other',
leaf: false,
children: floatingSettings
})
}
return root
})
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
// Define panel nodes
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
children: []
}
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const extensionPanelNode: SettingTreeNode = {
key: 'extension',
label: 'Extension',
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
/**
* Server config panel is only available in Electron
*/
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
return isElectron() ? [serverConfigPanelNode] : []
})
/**
* The default category to show when the dialog is opened.
*/
const defaultCategory = computed<SettingTreeNode>(() => {
return defaultPanel
? settingCategories.value.find((x) => x.key === defaultPanel) ??
settingCategories.value[0]
: settingCategories.value[0]
})
const translateCategory = (node: SettingTreeNode) => ({
...node,
translatedLabel: t(
`settingsCategories.${normalizeI18nKey(node.label)}`,
node.label
)
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Normal settings stored in the settingStore
{
key: 'settings',
label: 'Application Settings',
children: settingCategories.value.map(translateCategory)
},
// Special settings such as about, keybinding, extension, server-config
{
key: 'specialSettings',
label: 'Special Settings',
children: [
keybindingPanelNode,
extensionPanelNode,
aboutPanelNode,
...serverConfigPanelNodeList.value
].map(translateCategory)
}
])
onMounted(() => {
activeCategory.value = defaultCategory.value
})
return {
activeCategory,
defaultCategory,
groupedMenuTreeNodes,
settingCategories
}
}

View File

@@ -5,6 +5,7 @@ export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: any) => {
console.error(error)
toast.add({
severity: 'error',
summary: t('g.error'),

View File

@@ -18,7 +18,6 @@ import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { generateUUID } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
@@ -32,7 +31,6 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: MultiSelectWidget,

View File

@@ -37,6 +37,7 @@ export const useImageUploadWidget = () => {
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
@@ -92,7 +93,9 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
node.graph?.setDirtyCanvas(true)
}
@@ -100,7 +103,9 @@ export const useImageUploadWidget = () => {
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
})

View File

@@ -1,6 +1,17 @@
import { FirebaseOptions } from 'firebase/app'
export const FIREBASE_CONFIG: FirebaseOptions = {
const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com',
databaseURL: 'https://dreamboothy-dev-default-rtdb.firebaseio.com',
projectId: 'dreamboothy-dev',
storageBucket: 'dreamboothy-dev.appspot.com',
messagingSenderId: '313257147182',
appId: '1:313257147182:web:be38f6ebf74345fc7618bf',
measurementId: 'G-YEVSMYXSPY'
}
const PROD_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs',
authDomain: 'dreamboothy.firebaseapp.com',
databaseURL: 'https://dreamboothy-default-rtdb.firebaseio.com',
@@ -10,3 +21,9 @@ export const FIREBASE_CONFIG: FirebaseOptions = {
appId: '1:357148958219:web:f5917f72e5f36a2015310e',
measurementId: 'G-3ZBD3MBTG4'
}
// To test with prod config while using dev server, set USE_PROD_FIREBASE_CONFIG=true in .env
// Otherwise, build with `npm run build` the and set `--front-end-root` to `ComfyUI_frontend/dist`
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_FIREBASE_CONFIG__
? PROD_CONFIG
: DEV_CONFIG

View File

@@ -14,7 +14,6 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.Load3D',
@@ -118,7 +117,6 @@ useExtensionService().registerExtension({
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,
@@ -259,7 +257,6 @@ useExtensionService().registerExtension({
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3DAnimation,
@@ -355,7 +352,6 @@ useExtensionService().registerExtension({
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,
@@ -432,7 +428,6 @@ useExtensionService().registerExtension({
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3DAnimation,

View File

@@ -7,7 +7,6 @@ import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.SaveGLB',
@@ -30,7 +29,6 @@ useExtensionService().registerExtension({
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,

View File

@@ -14,7 +14,8 @@ const isMediaUploadComboInput = (inputSpec: InputSpec) => {
const isUploadInput =
inputOptions['image_upload'] === true ||
inputOptions['video_upload'] === true
inputOptions['video_upload'] === true ||
inputOptions['animated_image_upload'] === true
return (
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')

View File

@@ -47,7 +47,7 @@ export class PrimitiveNode extends LGraphNode {
applyToGraph(extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length) return
let links = [
const links = [
...this.outputs[0].links.map((l) => app.graph.links[l]),
...extraLinks
]
@@ -58,30 +58,35 @@ export class PrimitiveNode extends LGraphNode {
// For each output link copy our value over the original widget value
for (const linkInfo of links) {
// @ts-expect-error fixme ts strict error
const node = this.graph.getNodeById(linkInfo.target_id)
// @ts-expect-error fixme ts strict error
const input = node.inputs[linkInfo.target_slot]
let widget: IWidget | undefined
const widgetName = (input.widget as { name: string }).name
if (widgetName) {
// @ts-expect-error fixme ts strict error
widget = node.widgets.find((w) => w.name === widgetName)
const node = this.graph?.getNodeById(linkInfo.target_id)
const input = node?.inputs[linkInfo.target_slot]
if (!input) {
console.warn('Unable to resolve node or input for link', linkInfo)
continue
}
if (widget) {
widget.value = v
if (widget.callback) {
widget.callback(
widget.value,
app.canvas,
// @ts-expect-error fixme ts strict error
node,
app.canvas.graph_mouse,
{} as CanvasMouseEvent
)
}
const widgetName = input.widget?.name
if (!widgetName) {
console.warn('Invalid widget or widget name', input.widget)
continue
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) {
console.warn(
`Unable to find widget "${widgetName}" on node [${node.id}]`
)
continue
}
widget.value = v
widget.callback?.(
widget.value,
app.canvas,
node,
app.canvas.graph_mouse,
{} as CanvasMouseEvent
)
}
}

View File

@@ -463,8 +463,7 @@
"ControlNet": "ControlNet",
"Upscaling": "Upscaling",
"Video": "Video",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "Image",
"Area Composition": "Area Composition",
"3D": "3D",
"Audio": "Audio"
@@ -510,20 +509,23 @@
"ltxv_image_to_video": "LTXV Image to Video",
"ltxv_text_to_video": "LTXV Text to Video",
"mochi_text_to_video_example": "Mochi Text to Video",
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video"
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
"wan2_1_fun_inp": "Wan 2.1 Inpainting",
"wan2_1_fun_control": "Wan 2.1 ControlNet"
},
"SD3_5": {
"Image": {
"sd3_5_simple_example": "SD3.5 Simple",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Large Depth",
"sd3_5_large_blur": "SD3.5 Large Blur"
},
"SDXL": {
"sd3_5_large_blur": "SD3.5 Large Blur",
"sdxl_simple_example": "SDXL Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxlturbo_example": "SDXL Turbo"
"sdxlturbo_example": "SDXL Turbo",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full"
},
"Area Composition": {
"area_composition": "Area Composition",

View File

@@ -969,8 +969,7 @@
"ControlNet": "ControlNet",
"Custom Nodes": "Nodos Personalizados",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "Imagen",
"Upscaling": "Ampliación",
"Video": "Video"
},
@@ -1015,13 +1014,14 @@
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Rápido",
"hidream_i1_full": "HiDream I1 Completo",
"sd3_5_large_blur": "SD3.5 Grande Desenfoque",
"sd3_5_large_canny_controlnet_example": "SD3.5 Grande Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Grande Profundidad",
"sd3_5_simple_example": "SD3.5 Simple"
},
"SDXL": {
"sd3_5_simple_example": "SD3.5 Simple",
"sdxl_refiner_prompt_example": "SDXL Refinador de Solicitud",
"sdxl_revision_text_prompts": "SDXL Revisión de Solicitud de Texto",
"sdxl_revision_zero_positive": "SDXL Revisión Cero Positivo",
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "LTXV Texto a Video",
"mochi_text_to_video_example": "Mochi Texto a Video",
"text_to_video_wan": "Wan 2.1 Texto a Video",
"txt_to_image_to_video": "SVD Texto a Imagen a Video"
"txt_to_image_to_video": "SVD Texto a Imagen a Video",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 Relleno"
}
},
"title": "Comienza con una Plantilla"

View File

@@ -969,8 +969,7 @@
"ControlNet": "ControlNet",
"Custom Nodes": "Nœuds personnalisés",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "Image",
"Upscaling": "Mise à l'échelle",
"Video": "Vidéo"
},
@@ -1015,13 +1014,14 @@
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Rapide",
"hidream_i1_full": "HiDream I1 Complet",
"sd3_5_large_blur": "SD3.5 Grand Flou",
"sd3_5_large_canny_controlnet_example": "SD3.5 Grand Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Grande Profondeur",
"sd3_5_simple_example": "SD3.5 Simple"
},
"SDXL": {
"sd3_5_simple_example": "SD3.5 Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "Révisions de Texte SDXL",
"sdxl_revision_zero_positive": "Révision Zéro Positive SDXL",
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "LTXV Texte à Vidéo",
"mochi_text_to_video_example": "Exemple de Texte à Vidéo Mochi",
"text_to_video_wan": "Wan 2.1 Texte à Vidéo",
"txt_to_image_to_video": "Texte à Image à Vidéo"
"txt_to_image_to_video": "Texte à Image à Vidéo",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 Inpainting"
}
},
"title": "Commencez avec un modèle"

View File

@@ -969,8 +969,7 @@
"ControlNet": "ControlNet",
"Custom Nodes": "カスタムノード",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "画像",
"Upscaling": "アップスケーリング",
"Video": "ビデオ"
},
@@ -1015,13 +1014,14 @@
"flux_redux_model_example": "Flux Reduxモデル",
"flux_schnell": "Flux Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"sd3_5_large_blur": "SD3.5 ラージブラー",
"sd3_5_large_canny_controlnet_example": "SD3.5 ラージキャニーコントロールネット",
"sd3_5_large_depth": "SD3.5 ラージデプス",
"sd3_5_simple_example": "SD3.5 シンプル"
},
"SDXL": {
"sd3_5_simple_example": "SD3.5 シンプル",
"sdxl_refiner_prompt_example": "SDXL Refinerプロンプト",
"sdxl_revision_text_prompts": "SDXL Revisionテキストプロンプト",
"sdxl_revision_zero_positive": "SDXL Revisionゼロポジティブ",
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "LTXVテキストからビデオへ",
"mochi_text_to_video_example": "Mochiテキストからビデオへ",
"text_to_video_wan": "Wan 2.1 テキストからビデオへ",
"txt_to_image_to_video": "テキストから画像へ、画像からビデオへ"
"txt_to_image_to_video": "テキストから画像へ、画像からビデオへ",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 インペインティング"
}
},
"title": "テンプレートを利用して開始"

View File

@@ -969,8 +969,7 @@
"ControlNet": "컨트롤넷",
"Custom Nodes": "사용자 정의 노드",
"Flux": "FLUX",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "이미지",
"Upscaling": "업스케일링",
"Video": "비디오"
},
@@ -1015,13 +1014,14 @@
"flux_redux_model_example": "FLUX Redux 모델 예제",
"flux_schnell": "FLUX Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 개발",
"hidream_i1_fast": "HiDream I1 빠른",
"hidream_i1_full": "HiDream I1 전체",
"sd3_5_large_blur": "SD3.5 Large 블러 컨트롤넷",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large 캐니 컨트롤넷",
"sd3_5_large_depth": "SD3.5 Large 깊이 컨트롤넷",
"sd3_5_simple_example": "간단한 SD3.5 예제"
},
"SDXL": {
"sd3_5_simple_example": "간단한 SD3.5 예제",
"sdxl_refiner_prompt_example": "SDXL 리파이너 프롬프트",
"sdxl_revision_text_prompts": "SDXL Revision 텍스트 프롬프트",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "텍스트 -> 동영상 (LTXV)",
"mochi_text_to_video_example": "텍스트 -> 동영상 (Mochi)",
"text_to_video_wan": "Wan 2.1 텍스트를 비디오로",
"txt_to_image_to_video": "텍스트 -> 이미지 -> 동영상"
"txt_to_image_to_video": "텍스트 -> 이미지 -> 동영상",
"wan2_1_fun_control": "Wan 2.1 컨트롤넷",
"wan2_1_fun_inp": "Wan 2.1 인페인트"
}
},
"title": "템플릿으로 시작하기"

View File

@@ -969,8 +969,7 @@
"ControlNet": "ControlNet",
"Custom Nodes": "Пользовательские узлы",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "Изображение",
"Upscaling": "Увеличение разрешения",
"Video": "Видео"
},
@@ -1015,17 +1014,18 @@
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"sd3_5_large_blur": "SD3.5 Большое размытие",
"sd3_5_large_canny_controlnet_example": "SD3.5 Большой Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Большая глубина",
"sd3_5_simple_example": "SD3.5 Простой"
},
"SDXL": {
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxl_simple_example": "SDXL Simple",
"sd3_5_simple_example": "SD3.5 Простой",
"sdxl_refiner_prompt_example": "SDXL Уточняющий запрос",
"sdxl_revision_text_prompts": "SDXL Редактирование текстовых запросов",
"sdxl_revision_zero_positive": "SDXL Редактирование нулевого положительного",
"sdxl_simple_example": "SDXL Простой",
"sdxlturbo_example": "SDXL Turbo"
},
"Upscaling": {
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "LTXV Text to Video",
"mochi_text_to_video_example": "Mochi Text to Video",
"text_to_video_wan": "Wan 2.1 Текст в Видео",
"txt_to_image_to_video": "Текст в изображение в видео"
"txt_to_image_to_video": "Текст в изображение в видео",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 Inpainting"
}
},
"title": "Начните с шаблона"

View File

@@ -969,8 +969,7 @@
"ControlNet": "ControlNet",
"Custom Nodes": "自定义节点",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Image": "图片",
"Upscaling": "放大",
"Video": "视频"
},
@@ -1000,7 +999,7 @@
"lora_multiple": "Lora多个"
},
"ControlNet": {
"2_pass_pose_worship": "2通道姿势崇拜",
"2_pass_pose_worship": "通道姿势处理",
"controlnet_example": "ControlNet",
"depth_controlnet": "深度ControlNet",
"depth_t2i_adapter": "深度T2I适配器",
@@ -1015,13 +1014,14 @@
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell"
},
"SD3_5": {
"Image": {
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"sd3_5_large_blur": "SD3.5 Large 模糊",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny 控制网",
"sd3_5_large_depth": "SD3.5 Large 深度",
"sd3_5_simple_example": "SD3.5 简易示例"
},
"SDXL": {
"sd3_5_simple_example": "SD3.5 简易示例",
"sdxl_refiner_prompt_example": "SDXL Refiner提示",
"sdxl_revision_text_prompts": "SDXL修订文本提示",
"sdxl_revision_zero_positive": "SDXL修订零正",
@@ -1042,7 +1042,9 @@
"ltxv_text_to_video": "LTXV文本到视频",
"mochi_text_to_video_example": "Mochi文本到视频",
"text_to_video_wan": "Wan 2.1 文字到视频",
"txt_to_image_to_video": "文本到图像到视频"
"txt_to_image_to_video": "文本到图像到视频",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 图像修复"
}
},
"title": "从模板开始"

View File

@@ -74,6 +74,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
image_folder: z.enum(['input', 'output', 'temp']).optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */

View File

@@ -24,7 +24,11 @@ import type {
User,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
NodeId
} from '@/schemas/comfyWorkflowSchema'
import {
type ComfyNodeDef,
validateComfyNodeDef
@@ -33,13 +37,27 @@ import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
client_id: string
// Mapping from node id to node info + input values
// TODO: Type this.
prompt: Record<number, any>
prompt: ComfyApiWorkflow
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON
}
/**
* 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
@classmethod
def INPUT_TYPES(s):
return {
"hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG"}
}
def execute(self, auth_token: str):
print(f"Auth token: {auth_token}")
* ```
*/
auth_token_comfy_org?: string
}
front?: boolean
number?: number
@@ -499,19 +517,23 @@ export class ComfyApi extends EventTarget {
* Queues a prompt to be executed
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
* @param {object} prompt The prompt data to queue
* @param {string} authToken The auth token for the comfy org account if the user is logged in
* @throws {PromptExecutionError} If the prompt fails to execute
*/
async queuePrompt(
number: number,
{
output,
workflow
}: { output: Record<number, any>; workflow: ComfyWorkflowJSON }
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON },
authToken?: string
): Promise<PromptResponse> {
const { output: prompt, workflow } = data
const body: QueuePromptRequestBody = {
client_id: this.clientId ?? '', // TODO: Unify clientId access
prompt: output,
extra_data: { extra_pnginfo: { workflow } }
prompt,
extra_data: {
auth_token_comfy_org: authToken,
extra_pnginfo: { workflow }
}
}
if (number === -1) {

View File

@@ -34,6 +34,7 @@ import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -1172,6 +1173,16 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
let comfyOrgAuthToken =
(await useFirebaseAuthStore().getIdToken()) ?? undefined
// Check if we're in a secure context before using the auth token
if (comfyOrgAuthToken && !window.isSecureContext) {
comfyOrgAuthToken = undefined
console.warn(
'Auth token not used: Not in a secure context. Authentication requires a secure connection.'
)
}
try {
while (this.#queueItems.length) {
const { number, batchCount } = this.#queueItems.pop()!
@@ -1183,7 +1194,7 @@ export class ComfyApp {
const p = await this.graphToPrompt()
try {
const res = await api.queuePrompt(number, p)
const res = await api.queuePrompt(number, p, comfyOrgAuthToken)
executionStore.lastNodeErrors = res.node_errors ?? null
if (executionStore.lastNodeErrors?.length) {
this.canvas.draw(true, true)

View File

@@ -103,7 +103,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
readonly node: LGraphNode
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
@@ -114,7 +113,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
this.name = obj.name
this.options = obj.options
this.id = obj.id
this.id = generateUUID()
this.node = obj.node
}
@@ -172,7 +171,6 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
readonly element: T
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
@@ -235,7 +233,6 @@ export class ComponentWidgetImpl<V extends object | string>
readonly inputSpec: InputSpec
constructor(obj: {
id: string
node: LGraphNode
name: string
component: Component
@@ -293,7 +290,6 @@ LGraphNode.prototype.addDOMWidget = function <
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
const widget = new DOMWidgetImpl({
id: generateUUID(),
node: this,
name,
type,

View File

@@ -92,6 +92,10 @@ export function createImageHost(node) {
}
return {
el,
getCurrentImage() {
// @ts-expect-error fixme ts strict error
return currentImgs?.[0]
},
// @ts-expect-error fixme ts strict error
updateImages(imgs) {
// @ts-expect-error fixme ts strict error

View File

@@ -1,4 +1,3 @@
import * as formatUtil from '@/utils/formatUtil'
import { applyTextReplacements as _applyTextReplacements } from '@/utils/searchAndReplace'
import { api } from './api'
@@ -122,7 +121,3 @@ export function setStorageValue(id: string, value: string) {
}
localStorage.setItem(id, value)
}
export function generateUUID() {
return formatUtil.generateUUID()
}

View File

@@ -11,7 +11,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
import { useDialogService } from './dialogService'
@@ -95,7 +95,7 @@ export const useWorkflowService = () => {
await workflowStore.saveWorkflow(workflow)
} else {
// Generate new id when saving existing workflow as a new file
const id = crypto.randomUUID()
const id = generateUUID()
const state = JSON.parse(
JSON.stringify(workflow.activeState)
) as ComfyWorkflowJSON

View File

@@ -1,10 +1,13 @@
import {
type Auth,
GithubAuthProvider,
GoogleAuthProvider,
type User,
type UserCredential,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signOut
} from 'firebase/auth'
import { defineStore } from 'pinia'
@@ -18,6 +21,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
// Providers
const googleProvider = new GoogleAuthProvider()
const githubProvider = new GithubAuthProvider()
// Getters
const isAuthenticated = computed(() => !!currentUser.value)
const userEmail = computed(() => currentUser.value?.email)
@@ -68,6 +75,16 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
createUserWithEmailAndPassword(authInstance, email, password)
)
const loginWithGoogle = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, googleProvider)
)
const loginWithGithub = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, githubProvider)
)
const logout = async (): Promise<void> =>
executeAuthAction((authInstance) => signOut(authInstance))
@@ -94,6 +111,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
login,
register,
logout,
getIdToken
getIdToken,
loginWithGoogle,
loginWithGithub
}
})

View File

@@ -8,10 +8,12 @@ import { isVideoNode } from '@/utils/litegraphUtil'
const createOutputs = (
filenames: string[],
type: string
type: string,
isAnimated: boolean
): ExecutedWsMessage['output'] => {
return {
images: filenames.map((image) => ({ type, ...parseFilePath(image) }))
images: filenames.map((image) => ({ type, ...parseFilePath(image) })),
animated: filenames.map((image) => isAnimated && image.endsWith('.webp'))
}
}
@@ -52,18 +54,21 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function setNodeOutputs(
node: LGraphNode,
filenames: string | string[] | ResultItem,
{ folder = 'input' }: { folder?: string } = {}
{
folder = 'input',
isAnimated = false
}: { folder?: string; isAnimated?: boolean } = {}
) {
if (!filenames || !node) return
const nodeId = getNodeId(node)
if (typeof filenames === 'string') {
app.nodeOutputs[nodeId] = createOutputs([filenames], folder)
app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated)
} else if (!Array.isArray(filenames)) {
app.nodeOutputs[nodeId] = filenames
} else {
const resultItems = createOutputs(filenames, folder)
const resultItems = createOutputs(filenames, folder, isAnimated)
if (!resultItems?.images?.length) return
app.nodeOutputs[nodeId] = resultItems
}

View File

@@ -1,13 +1,12 @@
import _ from 'lodash'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { buildTree } from '@/utils/treeUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -38,28 +37,6 @@ export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Record<string, any>>({})
const settingsById = ref<Record<string, SettingParams>>({})
const settingTree = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingsById.value).filter(
(setting: SettingParams) => setting.type !== 'hidden'
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)
const floatingSettings = (root.children ?? []).filter((node) => node.leaf)
if (floatingSettings.length) {
root.children = (root.children ?? []).filter((node) => !node.leaf)
root.children.push({
key: 'Other',
label: 'Other',
leaf: false,
children: floatingSettings
})
}
return root
})
/**
* Check if a setting's value exists, i.e. if the user has set it manually.
* @param key - The key of the setting to check.
@@ -150,7 +127,6 @@ export const useSettingStore = defineStore('setting', () => {
return {
settingValues,
settingsById,
settingTree,
addSetting,
loadSettingValues,
set,

View File

@@ -1,8 +1,8 @@
import { groupBy } from 'lodash'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { api } from '@/scripts/api'
import type {
TemplateGroup,
@@ -13,7 +13,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
const { t } = useI18n()
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
@@ -22,11 +21,9 @@ export const useWorkflowTemplatesStore = defineStore(
const allTemplates = [
...coreTemplates.value.map((template) => ({
...template,
title: t(
title: st(
`templateWorkflows.category.${normalizeI18nKey(template.title)}`,
{
defaultValue: template.title
}
template.title ?? template.moduleName
)
})),
...Object.entries(customTemplates.value).map(
@@ -46,12 +43,11 @@ export const useWorkflowTemplatesStore = defineStore(
return Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? t('templateWorkflows.category.ComfyUI Examples', {
defaultValue: 'ComfyUI Examples'
})
: t('templateWorkflows.category.Custom Nodes', {
defaultValue: 'Custom Nodes'
})
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
})

View File

@@ -1,5 +1,9 @@
export interface TemplateInfo {
name: string
/**
* Optional title which is used as the fallback if the name is not in the locales dictionary.
*/
title?: string
tutorialUrl?: string
mediaType: string
mediaSubtype: string

View File

@@ -10,3 +10,20 @@ export const is_all_same_aspect_ratio = (imgs: HTMLImageElement[]): boolean => {
return true
}
export const fitDimensionsToNodeWidth = (
width: number,
height: number,
nodeWidth: number,
minHeight: number = 64
): { minHeight: number; minWidth: number } => {
const intrinsicAspectRatio = width / height
if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio))
return { minHeight: 0, minWidth: 0 }
// Set min. height s.t. image spans node's x-axis while maintaining aspect ratio
const minWidth = nodeWidth
const calculatedHeight = Math.max(minWidth / intrinsicAspectRatio, minHeight)
return { minHeight: calculatedHeight, minWidth }
}

View File

@@ -13,7 +13,10 @@ vi.mock('firebase/auth', () => ({
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn()
onAuthStateChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: vi.fn(),
GithubAuthProvider: vi.fn()
}))
describe('useFirebaseAuthStore', () => {
@@ -272,4 +275,90 @@ describe('useFirebaseAuthStore', () => {
expect(tokenAfterLogout).toBeNull()
})
})
describe('social authentication', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const result = await store.loginWithGoogle()
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GoogleAuthProvider)
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle Google sign in errors', async () => {
const mockError = new Error('Google authentication failed')
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError)
await expect(store.loginWithGoogle()).rejects.toThrow(
'Google authentication failed'
)
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GoogleAuthProvider)
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Google authentication failed')
})
})
describe('loginWithGithub', () => {
it('should sign in with Github', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const result = await store.loginWithGithub()
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GithubAuthProvider)
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle Github sign in errors', async () => {
const mockError = new Error('Github authentication failed')
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError)
await expect(store.loginWithGithub()).rejects.toThrow(
'Github authentication failed'
)
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GithubAuthProvider)
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Github authentication failed')
})
})
it('should handle concurrent social login attempts correctly', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const googleLoginPromise = store.loginWithGoogle()
const githubLoginPromise = store.loginWithGithub()
await Promise.all([googleLoginPromise, githubLoginPromise])
expect(store.loading).toBe(false)
})
})
})

View File

@@ -37,7 +37,6 @@ describe('useSettingStore', () => {
it('should initialize with empty settings', () => {
expect(store.settingValues).toEqual({})
expect(store.settingsById).toEqual({})
expect(store.settingTree.children).toEqual([])
})
describe('loadSettingValues', () => {

View File

@@ -184,7 +184,9 @@ export default defineConfig({
),
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || '')
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_FIREBASE_CONFIG__:
process.env.USE_PROD_FIREBASE_CONFIG === 'true'
},
resolve: {