mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 17:52:19 +00:00
Compare commits
8 Commits
split/ui-f
...
feat/vfx-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672b1363e8 | ||
|
|
2c14ee428a | ||
|
|
1d07692f5e | ||
|
|
ceb5ae1eba | ||
|
|
9f880c78cb | ||
|
|
3b2eb50f3b | ||
|
|
a539e02613 | ||
|
|
aacde8db0c |
@@ -39,3 +39,9 @@ export const NoBackdrop: Story = {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const DarkTone: Story = {
|
||||
args: {
|
||||
tone: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
tone = 'light',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
@@ -30,9 +31,19 @@ const {
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
tone?: 'light' | 'dark'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Light tone assumes a light backdrop: dark ink text on desktop plus a
|
||||
// warm-white radial scrim for contrast. Dark tone assumes a dark backdrop
|
||||
// and uses white text at all breakpoints, with no scrim.
|
||||
const toneTextClass = computed(() =>
|
||||
tone === 'light'
|
||||
? 'text-primary-comfy-canvas lg:text-primary-comfy-ink'
|
||||
: 'text-white'
|
||||
)
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
@@ -151,6 +162,7 @@ const scrimStyle = {
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
v-if="tone === 'light'"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
@@ -165,22 +177,24 @@ const scrimStyle = {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
:class="
|
||||
cn(
|
||||
'mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line lg:text-6xl/tight',
|
||||
toneTextClass
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
:class="cn('mt-8 max-w-md text-base lg:text-lg', toneTextClass)"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
<p v-if="footnote" :class="cn('mt-10 text-sm', toneTextClass)">
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
|
||||
18
apps/website/src/components/blocks/LogosAll01.stories.ts
Normal file
18
apps/website/src/components/blocks/LogosAll01.stories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { clientLogos } from '../../data/clientLogos'
|
||||
import LogosAll01 from './LogosAll01.vue'
|
||||
|
||||
const meta: Meta<typeof LogosAll01> = {
|
||||
title: 'Website/Blocks/LogosAll01',
|
||||
component: LogosAll01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
logos: clientLogos
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
83
apps/website/src/components/blocks/LogosAll01.vue
Normal file
83
apps/website/src/components/blocks/LogosAll01.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface LogoItem {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
const { logos, testId = 'logos-all' } = defineProps<{
|
||||
logos: readonly LogoItem[]
|
||||
testId?: string
|
||||
}>()
|
||||
|
||||
// On mobile the single row splits into two counter-scrolling halves.
|
||||
const mobileRow1Logos = computed(() =>
|
||||
logos.slice(0, Math.ceil(logos.length / 2))
|
||||
)
|
||||
const mobileRow2Logos = computed(() => logos.slice(Math.ceil(logos.length / 2)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div :data-testid="`${testId}-desktop`" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo.src"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two rows on mobile -->
|
||||
<div
|
||||
:data-testid="`${testId}-mobile`"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo.src"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo.src"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-balance text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -1,82 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
'Netflix',
|
||||
'Nike',
|
||||
'Pixomondo',
|
||||
'Tencent',
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
import { clientLogos } from '../../data/clientLogos'
|
||||
import LogosAll01 from '../blocks/LogosAll01.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two rows on mobile -->
|
||||
<div
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<LogosAll01 :logos="clientLogos" test-id="social-proof" />
|
||||
</template>
|
||||
|
||||
19
apps/website/src/components/common/TutorialCard.stories.ts
Normal file
19
apps/website/src/components/common/TutorialCard.stories.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { vfxTutorials } from '../../data/vfxTutorials'
|
||||
import TutorialCard from './TutorialCard.vue'
|
||||
|
||||
const meta: Meta<typeof TutorialCard> = {
|
||||
title: 'Website/Common/TutorialCard',
|
||||
component: TutorialCard,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
tutorial: vfxTutorials[0],
|
||||
titlePrefixKey: 'vfx.tutorials.titlePrefix'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
120
apps/website/src/components/common/TutorialCard.vue
Normal file
120
apps/website/src/components/common/TutorialCard.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { VideoTrack } from './VideoPlayer.vue'
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
|
||||
export interface Tutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const getTutorialPosterSrc = (tutorial: Tutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
const {
|
||||
tutorial,
|
||||
locale = 'en',
|
||||
titlePrefixKey
|
||||
} = defineProps<{
|
||||
tutorial: Tutorial
|
||||
locale?: Locale
|
||||
titlePrefixKey: TranslationKey
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ play: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t(titlePrefixKey, locale)} ${tutorial.title[locale]}`"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug">
|
||||
{{ t(titlePrefixKey, locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { Tutorial } from './TutorialCard.vue'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import VideoPlayer from './VideoPlayer.vue'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
const {
|
||||
tutorial,
|
||||
locale = 'en',
|
||||
titlePrefixKey
|
||||
} = defineProps<{
|
||||
tutorial: Tutorial
|
||||
locale?: Locale
|
||||
titlePrefixKey: TranslationKey
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -73,7 +78,7 @@ onUnmounted(() => {
|
||||
<h2
|
||||
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ t(titlePrefixKey, locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
@@ -3,14 +3,10 @@ import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { learningTutorials } from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
import TutorialCard from '../common/TutorialCard.vue'
|
||||
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
@@ -30,84 +26,13 @@ const activeTutorial = () =>
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<li v-for="tutorial in learningTutorials" :key="tutorial.id">
|
||||
<TutorialCard
|
||||
:tutorial="tutorial"
|
||||
:locale="locale"
|
||||
title-prefix-key="learning.tutorials.titlePrefix"
|
||||
@play="activeTutorialId = tutorial.id"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -115,6 +40,7 @@ const activeTutorial = () =>
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
title-prefix-key="learning.tutorials.titlePrefix"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
|
||||
43
apps/website/src/components/vfx/HeroSection.vue
Normal file
43
apps/website/src/components/vfx/HeroSection.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import HeroBackdrop01 from '../blocks/HeroBackdrop01.vue'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = computed(() => getRoutes(locale))
|
||||
|
||||
const backdrop = {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/vfx/vfx_hero.mp4'
|
||||
} as const
|
||||
|
||||
const mobileBackdrop = {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/vfx/vfx_hero-mobile.mp4'
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroBackdrop01
|
||||
tone="dark"
|
||||
:backdrop="backdrop"
|
||||
:mobile-backdrop="mobileBackdrop"
|
||||
:title="t('vfx.hero.title', locale)"
|
||||
:subtitle="t('vfx.hero.subtitle', locale)"
|
||||
>
|
||||
<div class="mt-10 flex flex-wrap gap-4">
|
||||
<Button as="a" :href="routes.contact">
|
||||
{{ t('vfx.cta.contactSales', locale) }}
|
||||
</Button>
|
||||
<Button as="a" :href="externalLinks.cloud" variant="outline">
|
||||
{{ t('vfx.hero.tryNow', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</HeroBackdrop01>
|
||||
</template>
|
||||
53
apps/website/src/components/vfx/TutorialsSection.vue
Normal file
53
apps/website/src/components/vfx/TutorialsSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { vfxTutorials } from '../../data/vfxTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import TutorialCard from '../common/TutorialCard.vue'
|
||||
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
vfxTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-6 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t('vfx.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="mx-auto mb-12 max-w-3xl text-center text-base text-primary-comfy-canvas lg:mb-16 lg:text-lg"
|
||||
>
|
||||
{{ t('vfx.tutorials.description', locale) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li v-for="tutorial in vfxTutorials" :key="tutorial.id">
|
||||
<TutorialCard
|
||||
:tutorial="tutorial"
|
||||
:locale="locale"
|
||||
title-prefix-key="vfx.tutorials.titlePrefix"
|
||||
@play="activeTutorialId = tutorial.id"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
title-prefix-key="vfx.tutorials.titlePrefix"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ const baseRoutes = {
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
vfx: '/vfx',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
|
||||
20
apps/website/src/data/clientLogos.ts
Normal file
20
apps/website/src/data/clientLogos.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { LogoItem } from '../components/blocks/LogosAll01.vue'
|
||||
|
||||
const names = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
'Netflix',
|
||||
'Nike',
|
||||
'Pixomondo',
|
||||
'Tencent',
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
export const clientLogos: readonly LogoItem[] = names.map((name) => ({
|
||||
src: `/icons/clients/${name}.svg`,
|
||||
alt: name
|
||||
}))
|
||||
@@ -1,28 +1,10 @@
|
||||
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
import type { Tutorial } from '../components/common/TutorialCard.vue'
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
export const learningTutorials: readonly LearningTutorial[] = [
|
||||
export const learningTutorials: readonly Tutorial[] = [
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
|
||||
118
apps/website/src/data/vfxTutorials.ts
Normal file
118
apps/website/src/data/vfxTutorials.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Tutorial } from '../components/common/TutorialCard.vue'
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const vfxTutorials: readonly Tutorial[] = [
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc: 'https://media.comfy.org/website/vfx/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: {
|
||||
en: 'Seedance Demo (ComfyUI)',
|
||||
'zh-CN': 'Seedance 演示(ComfyUI)'
|
||||
},
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
@@ -1546,6 +1546,46 @@ const translations = {
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// VfxHeroSection
|
||||
'vfx.hero.title': {
|
||||
en: 'ComfyUI for\nVisual Effects',
|
||||
'zh-CN': '面向视觉特效的\nComfyUI'
|
||||
},
|
||||
'vfx.hero.subtitle': {
|
||||
en: 'From ideation to production, ComfyUI gives VFX teams faster workflows and more controlled output — so what ships looks exactly like what you envisioned.',
|
||||
'zh-CN':
|
||||
'从创意到成片,ComfyUI 为视觉特效团队带来更快的工作流与更可控的产出——让最终交付与你的构想分毫不差。'
|
||||
},
|
||||
'vfx.hero.tryNow': {
|
||||
en: 'Try Now',
|
||||
'zh-CN': '立即体验'
|
||||
},
|
||||
|
||||
// VfxTutorialsSection
|
||||
'vfx.tutorials.heading': {
|
||||
en: 'See What’s Possible',
|
||||
'zh-CN': '见证无限可能'
|
||||
},
|
||||
'vfx.tutorials.description': {
|
||||
en: 'From concept, storyboarding to texture generation and compositing references — discover workflows built for every stage of VFX production. New workflows added regularly.',
|
||||
'zh-CN':
|
||||
'从概念设计、分镜脚本到纹理生成与合成参考——探索覆盖视觉特效制作各个环节的工作流。新工作流持续上新。'
|
||||
},
|
||||
'vfx.tutorials.titlePrefix': {
|
||||
en: 'Learn how to:',
|
||||
'zh-CN': '学习如何:'
|
||||
},
|
||||
|
||||
// VfxCallToActionSection
|
||||
'vfx.cta.heading': {
|
||||
en: 'Bring your ideas to life with ComfyUI, exactly as intended.',
|
||||
'zh-CN': '用 ComfyUI 将创意变为现实,一切尽如所想。'
|
||||
},
|
||||
'vfx.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
|
||||
25
apps/website/src/pages/vfx.astro
Normal file
25
apps/website/src/pages/vfx.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/vfx/HeroSection.vue'
|
||||
import TutorialsSection from '../components/vfx/TutorialsSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="VFX — Comfy">
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
25
apps/website/src/pages/zh-CN/vfx.astro
Normal file
25
apps/website/src/pages/zh-CN/vfx.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/vfx/HeroSection.vue'
|
||||
import TutorialsSection from '../../components/vfx/TutorialsSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="视效 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
|
||||
"chooseAnOption": "يرجى اختيار خيار.",
|
||||
"describeAnswer": "يرجى وصف إجابتك.",
|
||||
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
|
||||
},
|
||||
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
||||
"basics": "مرتاح مع الأساسيات",
|
||||
"expert": "خبير (أساعد الآخرين)",
|
||||
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
||||
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
||||
"experience": {
|
||||
"new": "جديد على ComfyUI",
|
||||
"pro": "أنا مستخدم محترف",
|
||||
"some": "لدي معرفة جيدة"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"pipelines": "مسارات مؤتمتة",
|
||||
"products": "منتجات للآخرين"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
|
||||
"api": "نقاط نهاية API لتشغيل مسارات العمل",
|
||||
"apps": "تطبيقات مبسطة من مسارات العمل",
|
||||
"audio": "صوت / موسيقى",
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
|
||||
"exploring": "أستكشف فقط",
|
||||
"images": "صور",
|
||||
"not_sure": "لست متأكداً",
|
||||
"videos": "فيديوهات",
|
||||
"other": "شيء آخر",
|
||||
"otherPlaceholder": "ماذا تريد أن تصنع؟",
|
||||
"video": "فيديو",
|
||||
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
|
||||
},
|
||||
"source": {
|
||||
"conference": "مؤتمر أو فعالية",
|
||||
"discord": "ديسكورد / مجتمع",
|
||||
"community": "مجتمع أو منتدى",
|
||||
"friend": "صديق أو زميل",
|
||||
"github": "GitHub",
|
||||
"other": "أخرى",
|
||||
"otherPlaceholder": "من أين وجدتنا؟",
|
||||
"search": "جوجل / بحث",
|
||||
"social": "وسائل التواصل الاجتماعي"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "ديسكورد",
|
||||
"instagram": "إنستغرام",
|
||||
"linkedin": "لينكدإن",
|
||||
"newsletter": "النشرة البريدية أو مدونة",
|
||||
"other": "أخرى",
|
||||
"reddit": "ريديت",
|
||||
"search": "جوجل / بحث",
|
||||
"twitter": "تويتر / X",
|
||||
"tiktok": "تيك توك",
|
||||
"twitter": "X (تويتر)",
|
||||
"youtube": "يوتيوب"
|
||||
},
|
||||
"usage": {
|
||||
"education": "تعليمي (طالب أو معلم)",
|
||||
"personal": "استخدام شخصي",
|
||||
"work": "عمل"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "أخبرنا المزيد",
|
||||
"placeholder": "نص بديل لأسئلة الاستبيان",
|
||||
"steps": {
|
||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"source": "من أين سمعت عن ComfyUI؟",
|
||||
"usage": "كيف تخطط لاستخدام ComfyUI؟"
|
||||
},
|
||||
"title": "استبيان السحابة"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "تعرف على السحابة",
|
||||
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
||||
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
||||
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_focus": "ماذا تبني؟",
|
||||
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
|
||||
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
|
||||
"cloudSurvey_steps_source_social": "أي منصة؟",
|
||||
"cloudWaitlist_contactLink": "هنا",
|
||||
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
||||
"color": {
|
||||
|
||||
@@ -2414,6 +2414,16 @@
|
||||
"tooltipLearnMore": "Learn more..."
|
||||
}
|
||||
},
|
||||
"desktopLogin": {
|
||||
"confirmSummary": "Approve desktop sign-in?",
|
||||
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
|
||||
"successSummary": "Signed in",
|
||||
"successDetail": "You can return to the ComfyUI desktop app.",
|
||||
"expiredSummary": "Desktop sign-in failed",
|
||||
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
|
||||
"failedSummary": "Desktop sign-in failed",
|
||||
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
|
||||
},
|
||||
"validation": {
|
||||
"invalidEmail": "Invalid email address",
|
||||
"required": "Required",
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, elige una opción.",
|
||||
"describeAnswer": "Por favor, describe tu respuesta.",
|
||||
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
|
||||
},
|
||||
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
|
||||
"basics": "Cómodo con lo básico",
|
||||
"expert": "Experto (ayudo a otros)",
|
||||
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
|
||||
"starting": "Recién comenzando (siguiendo tutoriales)"
|
||||
"experience": {
|
||||
"new": "Nuevo en ComfyUI",
|
||||
"pro": "Soy usuario avanzado",
|
||||
"some": "Ya tengo experiencia"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Productos para otros"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "Recursos 3D / recursos para juegos",
|
||||
"api": "Endpoints de API para ejecutar flujos de trabajo",
|
||||
"apps": "Apps simplificadas a partir de flujos de trabajo",
|
||||
"audio": "Audio / música",
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"apps_api": "Aplicaciones y APIs",
|
||||
"exploring": "Solo explorando",
|
||||
"images": "Imágenes",
|
||||
"not_sure": "No estoy seguro",
|
||||
"videos": "Videos",
|
||||
"other": "Otra cosa",
|
||||
"otherPlaceholder": "¿Qué quieres crear?",
|
||||
"video": "Video",
|
||||
"workflows": "Flujos de trabajo o pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"conference": "Conferencia o evento",
|
||||
"discord": "Discord / comunidad",
|
||||
"community": "Una comunidad o foro",
|
||||
"friend": "Amigo o colega",
|
||||
"github": "GitHub",
|
||||
"other": "Otro",
|
||||
"otherPlaceholder": "¿Dónde nos encontraste?",
|
||||
"search": "Google / búsqueda",
|
||||
"social": "Redes sociales"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter o blog",
|
||||
"other": "Otro",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / búsqueda",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educación (estudiante o docente)",
|
||||
"personal": "Uso personal",
|
||||
"work": "Trabajo"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Cuéntanos más",
|
||||
"placeholder": "Marcador de posición para preguntas de la encuesta",
|
||||
"steps": {
|
||||
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"usage": "¿Cómo planeas usar ComfyUI?"
|
||||
},
|
||||
"title": "Encuesta en la Nube"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
|
||||
"cloudStart_title": "comienza a crear en segundos",
|
||||
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
|
||||
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
|
||||
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
|
||||
"cloudWaitlist_contactLink": "aquí",
|
||||
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
|
||||
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
|
||||
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
|
||||
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
|
||||
},
|
||||
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسبسازی کنیم.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "کاربر پیشرفته (جریانکارهای سفارشی)",
|
||||
"basics": "آشنایی با مبانی",
|
||||
"expert": "کاربر خبره (به دیگران کمک میکنم)",
|
||||
"new": "جدید در ComfyUI (تا کنون استفاده نکردهام)",
|
||||
"starting": "تازه شروع کردهام (در حال دنبال کردن آموزشها)"
|
||||
"experience": {
|
||||
"new": "جدید در ComfyUI",
|
||||
"pro": "کاربر حرفهای هستم",
|
||||
"some": "آشنایی نسبی دارم"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodeهای سفارشی",
|
||||
"pipelines": "پایپلاینهای خودکار",
|
||||
"products": "محصولات برای دیگران"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "دارایی سهبعدی / دارایی بازی",
|
||||
"api": "API endpoint برای اجرای workflow",
|
||||
"apps": "اپلیکیشن سادهشده از workflow",
|
||||
"audio": "صدا / موسیقی",
|
||||
"custom_nodes": "node سفارشی",
|
||||
"apps_api": "اپلیکیشنها و APIها",
|
||||
"exploring": "فقط در حال بررسی",
|
||||
"images": "تصویر",
|
||||
"not_sure": "مطمئن نیستم",
|
||||
"videos": "ویدیو",
|
||||
"other": "چیز دیگری",
|
||||
"otherPlaceholder": "چه چیزی میخواهید بسازید؟",
|
||||
"video": "ویدیو",
|
||||
"workflows": "workflow یا pipeline سفارشی"
|
||||
},
|
||||
"source": {
|
||||
"conference": "کنفرانس یا رویداد",
|
||||
"discord": "Discord / انجمن",
|
||||
"community": "انجمن یا فروم",
|
||||
"friend": "دوست یا همکار",
|
||||
"github": "GitHub",
|
||||
"other": "سایر",
|
||||
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
|
||||
"search": "Google / جستجو",
|
||||
"social": "رسانههای اجتماعی"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "خبرنامه یا وبلاگ",
|
||||
"other": "سایر",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / جستجو",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "آموزشی (دانشجو یا مدرس)",
|
||||
"personal": "استفاده شخصی",
|
||||
"work": "کاری"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "بیشتر توضیح دهید",
|
||||
"placeholder": "جاینگهدار سوالات نظرسنجی",
|
||||
"steps": {
|
||||
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
|
||||
},
|
||||
"title": "نظرسنجی ابری"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
|
||||
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
|
||||
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
|
||||
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
|
||||
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
|
||||
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
|
||||
"cloudWaitlist_contactLink": "اینجا",
|
||||
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
|
||||
"chooseAnOption": "Veuillez choisir une option.",
|
||||
"describeAnswer": "Veuillez décrire votre réponse.",
|
||||
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
|
||||
},
|
||||
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "Utilisateur avancé (workflows personnalisés)",
|
||||
"basics": "À l'aise avec les bases",
|
||||
"expert": "Expert (j'aide les autres)",
|
||||
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
|
||||
"starting": "Je débute (je suis des tutoriels)"
|
||||
"experience": {
|
||||
"new": "Nouveau sur ComfyUI",
|
||||
"pro": "Utilisateur avancé",
|
||||
"some": "Je me débrouille"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"pipelines": "Pipelines automatisés",
|
||||
"products": "Produits pour les autres"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "Assets 3D / assets de jeu",
|
||||
"api": "Points de terminaison API pour exécuter des workflows",
|
||||
"apps": "Applications simplifiées à partir de workflows",
|
||||
"audio": "Audio / musique",
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"apps_api": "Applications et API",
|
||||
"exploring": "Je découvre simplement",
|
||||
"images": "Images",
|
||||
"not_sure": "Pas sûr",
|
||||
"videos": "Vidéos",
|
||||
"other": "Autre chose",
|
||||
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
|
||||
"video": "Vidéo",
|
||||
"workflows": "Workflows ou pipelines personnalisés"
|
||||
},
|
||||
"source": {
|
||||
"conference": "Conférence ou événement",
|
||||
"discord": "Discord / communauté",
|
||||
"community": "Une communauté ou un forum",
|
||||
"friend": "Ami ou collègue",
|
||||
"github": "GitHub",
|
||||
"other": "Autre",
|
||||
"otherPlaceholder": "Où nous avez-vous trouvés ?",
|
||||
"search": "Google / recherche",
|
||||
"social": "Réseaux sociaux"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Autre",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / recherche",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Éducation (étudiant ou enseignant)",
|
||||
"personal": "Usage personnel",
|
||||
"work": "Travail"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Dites-nous en plus",
|
||||
"placeholder": "Texte indicatif des questions de l'enquête",
|
||||
"steps": {
|
||||
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
|
||||
},
|
||||
"title": "Enquête Cloud"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
|
||||
"cloudStart_title": "créez en quelques secondes",
|
||||
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
|
||||
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
|
||||
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
|
||||
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
|
||||
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
|
||||
"cloudWaitlist_contactLink": "ici",
|
||||
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
|
||||
"chooseAnOption": "אנא בחר אפשרות.",
|
||||
"describeAnswer": "אנא תאר את תשובתך.",
|
||||
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
|
||||
},
|
||||
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
|
||||
"basics": "בינוני — מרגיש בנוח עם היסודות",
|
||||
"expert": "מומחה — אני עוזר לאחרים",
|
||||
"new": "חדש — מעולם לא השתמשתי",
|
||||
"starting": "מתחיל — עוקב אחר מדריכים"
|
||||
"experience": {
|
||||
"new": "חדש/ה ב-ComfyUI",
|
||||
"pro": "משתמש/ת מתקדם/ת",
|
||||
"some": "מכיר/ה את המערכת"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "צמתים מותאמים אישית",
|
||||
"pipelines": "צינורות עבודה אוטומטיים",
|
||||
"products": "מוצרים לאחרים"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
|
||||
"api": "נקודות קצה של API להרצת תהליכי עבודה",
|
||||
"apps": "יישומים מפושטים מתהליכי עבודה",
|
||||
"audio": "שמע / מוזיקה",
|
||||
"custom_nodes": "צמתים מותאמים",
|
||||
"apps_api": "אפליקציות ו-API",
|
||||
"exploring": "רק בודק/ת",
|
||||
"images": "תמונות",
|
||||
"not_sure": "לא בטוח",
|
||||
"videos": "סרטונים",
|
||||
"other": "משהו אחר",
|
||||
"otherPlaceholder": "מה תרצו ליצור?",
|
||||
"video": "וידאו",
|
||||
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
|
||||
},
|
||||
"source": {
|
||||
"conference": "כנס או אירוע",
|
||||
"discord": "Discord / קהילה",
|
||||
"community": "קהילה או פורום",
|
||||
"friend": "חבר או עמית",
|
||||
"github": "GitHub",
|
||||
"other": "אחר",
|
||||
"otherPlaceholder": "היכן שמעתם עלינו?",
|
||||
"search": "Google / חיפוש",
|
||||
"social": "רשתות חברתיות"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ניוזלטר או בלוג",
|
||||
"other": "אחר",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / חיפוש",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "חינוך (סטודנט או מרצה)",
|
||||
"personal": "שימוש אישי",
|
||||
"work": "עבודה"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "ספרו לנו עוד",
|
||||
"placeholder": "מציין מיקום לשאלות הסקר",
|
||||
"steps": {
|
||||
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"source": "היכן שמעת על ComfyUI?",
|
||||
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
|
||||
},
|
||||
"title": "סקר ענן"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "למד על הענן",
|
||||
"cloudStart_title": "התחל ליצור תוך שניות",
|
||||
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
|
||||
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "מה אתם בונים?",
|
||||
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
|
||||
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
|
||||
"cloudWaitlist_contactLink": "כאן",
|
||||
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "回答は{max}文字以内で入力してください。",
|
||||
"chooseAnOption": "オプションを選択してください。",
|
||||
"describeAnswer": "回答を記述してください。",
|
||||
"selectAtLeastOne": "少なくとも1つ選択してください。"
|
||||
},
|
||||
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "上級ユーザー(カスタムワークフロー)",
|
||||
"basics": "基本操作に慣れている",
|
||||
"expert": "エキスパート(他者を支援)",
|
||||
"new": "ComfyUI初心者(使用経験なし)",
|
||||
"starting": "使い始め(チュートリアルをフォロー中)"
|
||||
"experience": {
|
||||
"new": "ComfyUIは初めて",
|
||||
"pro": "上級ユーザー",
|
||||
"some": "ある程度使い方が分かる"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "カスタムノード",
|
||||
"pipelines": "自動パイプライン",
|
||||
"products": "他者向けプロダクト"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3Dアセット/ゲームアセット",
|
||||
"api": "ワークフロー実行用APIエンドポイント",
|
||||
"apps": "ワークフローから簡易アプリ作成",
|
||||
"audio": "音声/音楽",
|
||||
"custom_nodes": "カスタムノード",
|
||||
"apps_api": "アプリ・API",
|
||||
"exploring": "探索中",
|
||||
"images": "画像",
|
||||
"not_sure": "まだ分からない",
|
||||
"videos": "動画",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "何を作りたいですか?",
|
||||
"video": "動画",
|
||||
"workflows": "カスタムワークフローやパイプライン"
|
||||
},
|
||||
"source": {
|
||||
"conference": "カンファレンスやイベント",
|
||||
"discord": "Discord/コミュニティ",
|
||||
"community": "コミュニティ・フォーラム",
|
||||
"friend": "友人または同僚",
|
||||
"github": "GitHub",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "どこで私たちを知りましたか?",
|
||||
"search": "Google/検索",
|
||||
"social": "ソーシャルメディア"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ニュースレターやブログ",
|
||||
"other": "その他",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google/検索",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生または教育者)",
|
||||
"personal": "個人利用",
|
||||
"work": "仕事"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "詳細をお聞かせください",
|
||||
"placeholder": "アンケート質問のプレースホルダー",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
||||
"intent": "ComfyUIで何を作成したいですか?",
|
||||
"source": "ComfyUIをどこで知りましたか?",
|
||||
"usage": "ComfyUIをどのように利用する予定ですか?"
|
||||
},
|
||||
"title": "クラウドアンケート"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
|
||||
"cloudStart_title": "数秒で作成を開始",
|
||||
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか?",
|
||||
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは?",
|
||||
"cloudSurvey_steps_focus": "何を作成していますか?",
|
||||
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか?",
|
||||
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか?",
|
||||
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
|
||||
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
|
||||
"cloudWaitlist_contactLink": "こちら",
|
||||
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
|
||||
"chooseAnOption": "옵션을 선택해 주세요.",
|
||||
"describeAnswer": "답변을 설명해 주세요.",
|
||||
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
|
||||
},
|
||||
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
|
||||
"basics": "기본 기능에 익숙함",
|
||||
"expert": "전문가 (다른 사용자 도움)",
|
||||
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
|
||||
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
|
||||
"experience": {
|
||||
"new": "ComfyUI가 처음이에요",
|
||||
"pro": "전문 사용자입니다",
|
||||
"some": "기본적인 사용법을 알아요"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"pipelines": "자동화 파이프라인",
|
||||
"products": "타인을 위한 제품"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3D 에셋 / 게임 에셋",
|
||||
"api": "워크플로우 실행용 API 엔드포인트",
|
||||
"apps": "워크플로우 기반 간소화 앱",
|
||||
"audio": "오디오 / 음악",
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"apps_api": "앱 및 API",
|
||||
"exploring": "그냥 둘러보는 중",
|
||||
"images": "이미지",
|
||||
"not_sure": "잘 모르겠음",
|
||||
"videos": "비디오",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
|
||||
"video": "비디오",
|
||||
"workflows": "맞춤형 워크플로우 또는 파이프라인"
|
||||
},
|
||||
"source": {
|
||||
"conference": "컨퍼런스 또는 이벤트",
|
||||
"discord": "Discord / 커뮤니티",
|
||||
"community": "커뮤니티 또는 포럼",
|
||||
"friend": "친구 또는 동료",
|
||||
"github": "GitHub",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
|
||||
"search": "Google / 검색",
|
||||
"social": "소셜 미디어"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "뉴스레터 또는 블로그",
|
||||
"other": "기타",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / 검색",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "교육용(학생 또는 교육자)",
|
||||
"personal": "개인용",
|
||||
"work": "업무용"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "자세히 알려주세요",
|
||||
"placeholder": "설문 질문 자리표시자",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
|
||||
},
|
||||
"title": "클라우드 설문"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "클라우드 알아보기",
|
||||
"cloudStart_title": "몇 초 만에 제작 시작",
|
||||
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI를 얼마나 잘 알고 계신가요?",
|
||||
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
|
||||
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
|
||||
"cloudWaitlist_contactLink": "여기",
|
||||
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, escolha uma opção.",
|
||||
"describeAnswer": "Por favor, descreva sua resposta.",
|
||||
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
|
||||
},
|
||||
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
|
||||
"basics": "Confortável com o básico",
|
||||
"expert": "Especialista (ajuda outras pessoas)",
|
||||
"new": "Novo no ComfyUI (nunca usei antes)",
|
||||
"starting": "Começando agora (seguindo tutoriais)"
|
||||
"experience": {
|
||||
"new": "Novo no ComfyUI",
|
||||
"pro": "Sou um usuário avançado",
|
||||
"some": "Já conheço um pouco"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nós personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Produtos para outros"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "Assets 3D / assets para jogos",
|
||||
"api": "Endpoints de API para executar workflows",
|
||||
"apps": "Apps simplificados a partir de workflows",
|
||||
"audio": "Áudio / música",
|
||||
"custom_nodes": "Nodes personalizados",
|
||||
"apps_api": "Apps e APIs",
|
||||
"exploring": "Só explorando",
|
||||
"images": "Imagens",
|
||||
"not_sure": "Não tenho certeza",
|
||||
"videos": "Vídeos",
|
||||
"other": "Outra coisa",
|
||||
"otherPlaceholder": "O que você quer criar?",
|
||||
"video": "Vídeo",
|
||||
"workflows": "Workflows ou pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"conference": "Conferência ou evento",
|
||||
"discord": "Discord / comunidade",
|
||||
"community": "Uma comunidade ou fórum",
|
||||
"friend": "Amigo ou colega",
|
||||
"github": "GitHub",
|
||||
"other": "Outro",
|
||||
"otherPlaceholder": "Onde você nos encontrou?",
|
||||
"search": "Google / busca",
|
||||
"social": "Mídias sociais"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Outro",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / busca",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educação (estudante ou educador)",
|
||||
"personal": "Uso pessoal",
|
||||
"work": "Trabalho"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Conte-nos mais",
|
||||
"placeholder": "Espaço reservado para perguntas da pesquisa",
|
||||
"steps": {
|
||||
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"intent": "O que você deseja criar com o ComfyUI?",
|
||||
"source": "Onde você ouviu falar do ComfyUI?",
|
||||
"usage": "Como você pretende usar o ComfyUI?"
|
||||
},
|
||||
"title": "Pesquisa da Nuvem"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
|
||||
"cloudStart_title": "comece a criar em segundos",
|
||||
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
|
||||
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "O que você está construindo?",
|
||||
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
|
||||
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "Em qual plataforma?",
|
||||
"cloudWaitlist_contactLink": "aqui",
|
||||
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
|
||||
"chooseAnOption": "Пожалуйста, выберите вариант.",
|
||||
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
|
||||
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
|
||||
},
|
||||
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
|
||||
"basics": "Уверенно владею основами",
|
||||
"expert": "Эксперт (помогаю другим)",
|
||||
"new": "Новичок в ComfyUI (никогда не использовал)",
|
||||
"starting": "Только начинаю (следую руководствам)"
|
||||
"experience": {
|
||||
"new": "Впервые в ComfyUI",
|
||||
"pro": "Я опытный пользователь",
|
||||
"some": "Я немного знаком(а)"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Пользовательские узлы",
|
||||
"pipelines": "Автоматизированные пайплайны",
|
||||
"products": "Продукты для других"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3D-ассеты / игровые ассеты",
|
||||
"api": "API endpoints для запуска workflow",
|
||||
"apps": "Упрощённые приложения из workflow",
|
||||
"audio": "Аудио / музыка",
|
||||
"custom_nodes": "Пользовательские node",
|
||||
"apps_api": "Приложения и API",
|
||||
"exploring": "Просто изучаю",
|
||||
"images": "Изображения",
|
||||
"not_sure": "Не уверен",
|
||||
"videos": "Видео",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Что вы хотите создать?",
|
||||
"video": "Видео",
|
||||
"workflows": "Пользовательские workflow или pipeline"
|
||||
},
|
||||
"source": {
|
||||
"conference": "Конференция или мероприятие",
|
||||
"discord": "Discord / сообщество",
|
||||
"community": "Сообщество или форум",
|
||||
"friend": "Друг или коллега",
|
||||
"github": "GitHub",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Где вы о нас узнали?",
|
||||
"search": "Google / поиск",
|
||||
"social": "Социальные сети"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Новостная рассылка или блог",
|
||||
"other": "Другое",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / поиск",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Образование (студент или преподаватель)",
|
||||
"personal": "Личное использование",
|
||||
"work": "Работа"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Расскажите подробнее",
|
||||
"placeholder": "Вопросы для опроса",
|
||||
"steps": {
|
||||
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"source": "Где вы узнали о ComfyUI?",
|
||||
"usage": "Как вы планируете использовать ComfyUI?"
|
||||
},
|
||||
"title": "Облачный опрос"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "Узнать о Cloud",
|
||||
"cloudStart_title": "начать создавать за секунды",
|
||||
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
|
||||
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "Что вы создаёте?",
|
||||
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
|
||||
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "На какой платформе?",
|
||||
"cloudWaitlist_contactLink": "здесь",
|
||||
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
|
||||
"chooseAnOption": "Lütfen bir seçenek seçin.",
|
||||
"describeAnswer": "Lütfen cevabınızı açıklayın.",
|
||||
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
|
||||
},
|
||||
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
|
||||
"basics": "Temel bilgilerde rahatım",
|
||||
"expert": "Uzman (başkalarına yardım ediyorum)",
|
||||
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
|
||||
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
|
||||
"experience": {
|
||||
"new": "ComfyUI'ye yeni",
|
||||
"pro": "Güçlü bir kullanıcıyım",
|
||||
"some": "Biraz biliyorum"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"pipelines": "Otomatikleştirilmiş pipeline'lar",
|
||||
"products": "Başkaları için ürünler"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3D varlıklar / oyun varlıkları",
|
||||
"api": "İş akışlarını çalıştırmak için API uç noktaları",
|
||||
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
|
||||
"audio": "Ses / müzik",
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"apps_api": "Uygulamalar ve API'ler",
|
||||
"exploring": "Sadece keşfediyorum",
|
||||
"images": "Görseller",
|
||||
"not_sure": "Emin değilim",
|
||||
"videos": "Videolar",
|
||||
"other": "Başka bir şey",
|
||||
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
|
||||
"video": "Video",
|
||||
"workflows": "Özel iş akışları veya boru hatları"
|
||||
},
|
||||
"source": {
|
||||
"conference": "Konferans veya etkinlik",
|
||||
"discord": "Discord / topluluk",
|
||||
"community": "Bir topluluk veya forum",
|
||||
"friend": "Arkadaş veya iş arkadaşı",
|
||||
"github": "GitHub",
|
||||
"other": "Diğer",
|
||||
"otherPlaceholder": "Bizi nereden buldunuz?",
|
||||
"search": "Google / arama",
|
||||
"social": "Sosyal medya"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Bülten veya blog",
|
||||
"other": "Diğer",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / arama",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Eğitim (öğrenci veya eğitmen)",
|
||||
"personal": "Kişisel kullanım",
|
||||
"work": "İş"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Daha fazla bilgi verin",
|
||||
"placeholder": "Anket soruları yer tutucusu",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
||||
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"source": "ComfyUI'yi nereden duydunuz?",
|
||||
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
|
||||
},
|
||||
"title": "Bulut Anketi"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
|
||||
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
|
||||
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
|
||||
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
|
||||
"cloudSurvey_steps_source_social": "Hangi platform?",
|
||||
"cloudWaitlist_contactLink": "burada",
|
||||
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
|
||||
"chooseAnOption": "請選擇一個選項。",
|
||||
"describeAnswer": "請描述您的答案。",
|
||||
"selectAtLeastOne": "請至少選擇一個選項。"
|
||||
},
|
||||
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "進階使用者(自訂工作流程)",
|
||||
"basics": "熟悉基礎操作",
|
||||
"expert": "專家(協助他人)",
|
||||
"new": "ComfyUI 新手(從未使用過)",
|
||||
"starting": "剛開始(正在跟隨教學)"
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是進階使用者",
|
||||
"some": "我已經熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自訂節點",
|
||||
"pipelines": "自動化流程",
|
||||
"products": "為他人打造產品"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3D 素材/遊戲素材",
|
||||
"api": "執行工作流程的 API 端點",
|
||||
"apps": "由工作流程簡化的應用程式",
|
||||
"audio": "音訊/音樂",
|
||||
"custom_nodes": "自訂節點",
|
||||
"apps_api": "應用程式與 API",
|
||||
"exploring": "只是探索",
|
||||
"images": "圖像",
|
||||
"not_sure": "尚未確定",
|
||||
"videos": "影片",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您想製作什麼?",
|
||||
"video": "影片",
|
||||
"workflows": "自訂工作流程或管線"
|
||||
},
|
||||
"source": {
|
||||
"conference": "研討會或活動",
|
||||
"discord": "Discord/社群",
|
||||
"community": "社群或論壇",
|
||||
"friend": "朋友或同事",
|
||||
"github": "GitHub",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您是在哪裡發現我們的?",
|
||||
"search": "Google/搜尋引擎",
|
||||
"social": "社群媒體"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "電子報或部落格",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google/搜尋引擎",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育用途(學生或教育者)",
|
||||
"personal": "個人用途",
|
||||
"work": "工作用途"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "請告訴我們更多",
|
||||
"placeholder": "問卷問題佔位符",
|
||||
"steps": {
|
||||
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"intent": "您想用 ComfyUI 創作什麼?",
|
||||
"source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "雲端問卷"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "了解雲端服務",
|
||||
"cloudStart_title": "數秒內開始創作",
|
||||
"cloudStart_wantToRun": "想要在本機運行 ComfyUI?",
|
||||
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
|
||||
"cloudSurvey_steps_focus": "您正在製作什麼?",
|
||||
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
|
||||
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "哪個平台?",
|
||||
"cloudWaitlist_contactLink": "此處",
|
||||
"cloudWaitlist_questionsText": "有問題?聯絡我們",
|
||||
"color": {
|
||||
|
||||
@@ -515,57 +515,52 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
|
||||
"chooseAnOption": "请选择一个选项。",
|
||||
"describeAnswer": "请描述您的答案。",
|
||||
"selectAtLeastOne": "请至少选择一个选项。"
|
||||
},
|
||||
"intro": "帮助我们为您定制 ComfyUI 体验。",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "高级用户(自定义工作流)",
|
||||
"basics": "熟练掌握基础知识",
|
||||
"expert": "专家(帮助他人)",
|
||||
"new": "ComfyUI 新手(从未使用过)",
|
||||
"starting": "刚刚开始(正在学习教程)"
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是高级用户",
|
||||
"some": "我已经熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自定义节点",
|
||||
"pipelines": "自动化流程",
|
||||
"products": "为他人制作产品"
|
||||
},
|
||||
"intent": {
|
||||
"3d_game": "3D 资产 / 游戏资产",
|
||||
"api": "运行工作流的 API 端点",
|
||||
"apps": "基于工作流的简化应用",
|
||||
"audio": "音频 / 音乐",
|
||||
"custom_nodes": "自定义节点",
|
||||
"apps_api": "应用和 API",
|
||||
"exploring": "只是探索一下",
|
||||
"images": "图像",
|
||||
"not_sure": "不确定",
|
||||
"videos": "视频",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你想做什么?",
|
||||
"video": "视频",
|
||||
"workflows": "自定义工作流或流程"
|
||||
},
|
||||
"source": {
|
||||
"conference": "会议或活动",
|
||||
"discord": "Discord / 社区",
|
||||
"community": "社区或论坛",
|
||||
"friend": "朋友或同事",
|
||||
"github": "GitHub",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你是从哪里了解到我们的?",
|
||||
"search": "Google / 搜索",
|
||||
"social": "社交媒体"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "新闻通讯或博客",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"search": "Google / 搜索",
|
||||
"twitter": "Twitter / X",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(推特)",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生或教师)",
|
||||
"personal": "个人使用",
|
||||
"work": "工作"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "请告诉我们更多",
|
||||
"placeholder": "调查问题占位符",
|
||||
"steps": {
|
||||
"familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"intent": "您希望用 ComfyUI 创作什么?",
|
||||
"source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "云调研"
|
||||
}
|
||||
},
|
||||
@@ -578,10 +573,11 @@
|
||||
"cloudStart_learnAboutButton": "了解云服务",
|
||||
"cloudStart_title": "几秒钟内开始创作",
|
||||
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
|
||||
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解?",
|
||||
"cloudSurvey_steps_focus": "你正在构建什么?",
|
||||
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
|
||||
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的?",
|
||||
"cloudWaitlist_contactLink": "这里",
|
||||
"cloudWaitlist_questionsText": "有问题?联系我们",
|
||||
"color": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
|
||||
>
|
||||
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
|
||||
<h1
|
||||
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
|
||||
618
src/platform/cloud/onboarding/desktopLoginRedemption.test.ts
Normal file
618
src/platform/cloud/onboarding/desktopLoginRedemption.test.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Every test drives a real in-memory router and the real preserved-query
|
||||
* manager: the tracker strips the code from the URL at capture time, so the
|
||||
* stash is the only carrier, and redemption fires from router.afterEach, an
|
||||
* auth watcher, and a delayed retry after a transient failure.
|
||||
*
|
||||
* The fake clock (installed for every test) keeps those retry timers from
|
||||
* leaking into later tests: afterEach discards them with vi.useRealTimers().
|
||||
*/
|
||||
|
||||
const mockConfirm = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
confirm: mockConfirm
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockAuthStore {
|
||||
currentUser: {
|
||||
uid: string
|
||||
getIdToken: (forceRefresh?: boolean) => Promise<string>
|
||||
} | null
|
||||
getIdToken: () => Promise<string>
|
||||
}
|
||||
|
||||
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
|
||||
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Reactive so the module's watcher on currentUser fires without a navigation.
|
||||
// The mock factory is cached across vi.resetModules(), so it reads a holder
|
||||
// refilled per test; watchers leaked by earlier module generations stay
|
||||
// subscribed to earlier stores and remain dormant.
|
||||
const authStoreHolder = vi.hoisted(() => ({
|
||||
store: null as MockAuthStore | null
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreHolder.store
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
const VALID_CODE = `dlc_${'A'.repeat(43)}`
|
||||
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
|
||||
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
|
||||
const NAMESPACE = 'desktop_login'
|
||||
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
let mockAuthStore: MockAuthStore
|
||||
|
||||
function okResponse() {
|
||||
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
|
||||
}
|
||||
|
||||
function expectedFetchOptions(code: string) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer firebase-id-token',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
signal: expect.any(AbortSignal)
|
||||
}
|
||||
}
|
||||
|
||||
// The triggers fire-and-forget the redemption; a zero-length advance of the
|
||||
// fake clock yields the event loop so the whole mocked promise chain settles.
|
||||
async function flushRedemption() {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
// vi.resetModules() also resets the preserved-query manager's in-memory map,
|
||||
// so the manager must be imported alongside the module under test.
|
||||
async function setup() {
|
||||
const { installDesktopLoginRedemption } =
|
||||
await import('./desktopLoginRedemption')
|
||||
const { capturePreservedQuery, getPreservedQueryParam } =
|
||||
await import('@/platform/navigation/preservedQueryManager')
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
installDesktopLoginRedemption(router)
|
||||
|
||||
let navigationCount = 0
|
||||
const trigger = async () => {
|
||||
await router.push(`/trigger-${navigationCount++}`)
|
||||
await flushRedemption()
|
||||
}
|
||||
|
||||
return {
|
||||
router,
|
||||
trigger,
|
||||
seedStash: (code: string) =>
|
||||
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
|
||||
'desktop_login_code'
|
||||
]),
|
||||
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
|
||||
}
|
||||
}
|
||||
|
||||
describe('installDesktopLoginRedemption', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
sessionStorage.clear()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockFetch.mockReset()
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
|
||||
mockAuthStore = reactive({
|
||||
currentUser: {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
},
|
||||
getIdToken: mockStoreGetIdToken
|
||||
})
|
||||
authStoreHolder.store = mockAuthStore
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing on navigation when no code is stashed', async () => {
|
||||
const { trigger } = await setup()
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
title: 'desktopLogin.confirmSummary',
|
||||
message: 'desktopLogin.confirmMessage'
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'desktopLogin.successSummary',
|
||||
detail: 'desktopLogin.successDetail',
|
||||
life: 4000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch before the user approves the confirmation dialog', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['declines', false],
|
||||
['dismisses', null]
|
||||
] as const)(
|
||||
'clears the stash without a request or toast when the user %s the dialog',
|
||||
async ([_label, confirmResult]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockResolvedValue(confirmResult)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
// Declining is final for that code: re-capturing it never re-prompts.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('asks for approval at most once per code across transient retries', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('redeems a code hydrated lazily from sessionStorage', async () => {
|
||||
const { trigger } = await setup()
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ desktop_login_code: VALID_CODE })
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redeem or prompt again after a successful redemption', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A later navigation re-captures the already-redeemed code.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([400, 403, 404, 409, 410])(
|
||||
'clears the stash, shows an error toast, and never retries on %s',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.expiredSummary',
|
||||
detail: 'desktopLogin.expiredDetail',
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.for([401, 500])(
|
||||
'keeps the stash on %s for the scheduled retry without a toast',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.failedSummary',
|
||||
detail: 'desktopLogin.failedDetail',
|
||||
life: 6000
|
||||
})
|
||||
})
|
||||
|
||||
it('forces a token refresh on the retry after a 401', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a timeout signal and treats an aborted request as transient', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockRejectedValue(
|
||||
new DOMException('The operation timed out.', 'TimeoutError')
|
||||
)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an id token failure as transient without a toast', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
// authStore.getIdToken surfaces failures through a modal error dialog,
|
||||
// which this background flow must never trigger.
|
||||
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the stash without a dialog or request for a malformed code', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash('not-a-desktop-login-code')
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains an unexpected internal error instead of rejecting', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
|
||||
|
||||
await expect(trigger()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[DesktopLoginRedemption] Redemption failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockAuthStore.currentUser = null
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
// The first completed navigation installs the watcher; without a session
|
||||
// nothing redeems and the stash is kept.
|
||||
await trigger()
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// A session appearing without any further navigation redeems via the
|
||||
// watcher.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
|
||||
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
|
||||
] as const)(
|
||||
'gives a second code its own dialog and request after the first code %s',
|
||||
async ([_label, arrangeFirstOutcome]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
arrangeFirstOutcome()
|
||||
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
expect(stashedCode()).toBe(SECOND_CODE)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
// user-1 approves; the redeem fails transiently, keeping the code stashed.
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// The session changes to user-2 before the retry: user-1's approval must
|
||||
// not authorize redeeming with user-2's token.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
|
||||
const { seedStash, stashedCode, trigger } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The session swaps to user-2 while user-1's dialog is open: the stale
|
||||
// approval must not redeem, and the raced auth trigger is replayed to
|
||||
// re-prompt under user-2 without another navigation.
|
||||
const secondUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
mockAuthStore.currentUser = secondUser
|
||||
await flushRedemption()
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeds', () => okResponse()],
|
||||
['fails terminally', () => new Response(null, { status: 404 })]
|
||||
] as const)(
|
||||
'processes a newer code stashed mid-flight after the older redemption %s',
|
||||
async ([_label, firstResponse]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let resolveFirstFetch!: (response: Response) => void
|
||||
mockFetch.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirstFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A second code arrives while the first redemption is in flight; it
|
||||
// must survive the first's settlement and be processed right after.
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
resolveFirstFetch(firstResponse())
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces concurrent triggers into one dialog and one request', async () => {
|
||||
const { router, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await router.push('/burst-1')
|
||||
await router.push('/burst-2')
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
263
src/platform/cloud/onboarding/desktopLoginRedemption.ts
Normal file
263
src/platform/cloud/onboarding/desktopLoginRedemption.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { watch } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
|
||||
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
|
||||
|
||||
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
|
||||
// backend stays the authority on exact code length.
|
||||
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
|
||||
|
||||
// Statuses that mean the desktop app must start a fresh sign-in, so the code
|
||||
// is dropped. 401 stays transient: the session may still be settling.
|
||||
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
|
||||
|
||||
// One delayed in-page retry, so an approved sign-in always reaches a success
|
||||
// or failure toast without ever looping within a page load.
|
||||
const MAX_REDEEM_ATTEMPTS = 2
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
// Abort the redeem request if the backend hangs; treated as transient.
|
||||
const REDEEM_TIMEOUT_MS = 10_000
|
||||
|
||||
interface CodeRedemptionState {
|
||||
attempts: number
|
||||
approvedUserUid: string | null
|
||||
settled: boolean
|
||||
forceTokenRefresh: boolean
|
||||
}
|
||||
|
||||
// Keyed by code so a different code arriving later gets its own approval and
|
||||
// attempt budget, while retries of the same code reuse both.
|
||||
const codeStates = new Map<string, CodeRedemptionState>()
|
||||
|
||||
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
|
||||
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
|
||||
// more pass instead of being dropped.
|
||||
let draining = false
|
||||
let retriggerRequested = false
|
||||
|
||||
let authWatcherInstalled = false
|
||||
|
||||
function getCodeState(code: string): CodeRedemptionState {
|
||||
const existing = codeStates.get(code)
|
||||
if (existing) return existing
|
||||
const fresh = {
|
||||
attempts: 0,
|
||||
approvedUserUid: null,
|
||||
settled: false,
|
||||
forceTokenRefresh: false
|
||||
}
|
||||
codeStates.set(code, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
// A newer code can be stashed while an older one is mid-redemption; settling
|
||||
// the older one must not wipe it.
|
||||
function clearStashIfHolds(code: string): void {
|
||||
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
function settle(code: string, state: CodeRedemptionState): void {
|
||||
state.settled = true
|
||||
clearStashIfHolds(code)
|
||||
}
|
||||
|
||||
function handleTransientFailure(
|
||||
code: string,
|
||||
state: CodeRedemptionState,
|
||||
reason: string
|
||||
): void {
|
||||
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
|
||||
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
|
||||
// attempts only increments, so this branch runs at most once per code
|
||||
// and cannot stack retry timers.
|
||||
setTimeout(() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}, RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
// Budget spent: drop the code and tell the user instead of failing silently.
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.failedSummary'),
|
||||
detail: t('desktopLogin.failedDetail'),
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
|
||||
// Explicit approval defeats device-code phishing: a lured click on a leaked
|
||||
// link must not bind the victim's session to an attacker's desktop app.
|
||||
// Approval is per code *and* account.
|
||||
async function confirmRedemption(
|
||||
state: CodeRedemptionState,
|
||||
uid: string
|
||||
): Promise<boolean> {
|
||||
if (state.approvedUserUid === uid) return true
|
||||
const confirmed = await useDialogService().confirm({
|
||||
title: t('desktopLogin.confirmSummary'),
|
||||
message: t('desktopLogin.confirmMessage')
|
||||
})
|
||||
if (confirmed !== true) return false
|
||||
state.approvedUserUid = uid
|
||||
return true
|
||||
}
|
||||
|
||||
async function redeemCode(code: string): Promise<void> {
|
||||
const state = getCodeState(code)
|
||||
if (state.settled) {
|
||||
// A later navigation can re-capture an already-settled code; drop it.
|
||||
clearStashIfHolds(code)
|
||||
return
|
||||
}
|
||||
|
||||
// No session yet (e.g. code captured on the login page): keep the stash and
|
||||
// let a post-login trigger redeem it.
|
||||
const user = useAuthStore().currentUser
|
||||
if (!user) return
|
||||
|
||||
if (!(await confirmRedemption(state, user.uid))) {
|
||||
// Declined/dismissed: drop the code without an error.
|
||||
settle(code, state)
|
||||
return
|
||||
}
|
||||
|
||||
// Approval binds the code to one account: if the session changed while the
|
||||
// dialog was open, keep the code stashed and let the (replayed) auth-change
|
||||
// trigger re-prompt under the now-current account.
|
||||
const approvedUser = useAuthStore().currentUser
|
||||
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
|
||||
|
||||
state.attempts++
|
||||
|
||||
// Token comes straight from the Firebase user: authStore.getIdToken()
|
||||
// surfaces failures through a modal dialog this background flow must avoid.
|
||||
let idToken: string
|
||||
try {
|
||||
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
|
||||
} catch {
|
||||
handleTransientFailure(code, state, 'could not get id token')
|
||||
return
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// TODO(@comfyorg/ingest-types): type the payload with the generated
|
||||
// request type once the desktop-login-codes openapi addition propagates.
|
||||
body: JSON.stringify({ code }),
|
||||
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
handleTransientFailure(
|
||||
code,
|
||||
state,
|
||||
error instanceof Error && error.name === 'TimeoutError'
|
||||
? 'request timed out'
|
||||
: 'network error'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('desktopLogin.successSummary'),
|
||||
detail: t('desktopLogin.successDetail'),
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.expiredSummary'),
|
||||
detail: t('desktopLogin.expiredDetail'),
|
||||
life: 6000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A 401 usually means a stale cached id token; mint a fresh one on retry.
|
||||
if (response.status === 401) state.forceTokenRefresh = true
|
||||
handleTransientFailure(code, state, `status ${response.status}`)
|
||||
}
|
||||
|
||||
async function redeemPendingDesktopLoginCode(): Promise<void> {
|
||||
// Never rejects: the triggers fire-and-forget this.
|
||||
if (draining) {
|
||||
retriggerRequested = true
|
||||
return
|
||||
}
|
||||
draining = true
|
||||
try {
|
||||
do {
|
||||
retriggerRequested = false
|
||||
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
|
||||
if (!code) continue
|
||||
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
continue
|
||||
}
|
||||
await redeemCode(code)
|
||||
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
|
||||
retriggerRequested = true
|
||||
} while (retriggerRequested)
|
||||
} catch (error) {
|
||||
console.error('[DesktopLoginRedemption] Redemption failed:', error)
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
function installAuthWatcherOnce(): void {
|
||||
if (authWatcherInstalled) return
|
||||
authWatcherInstalled = true
|
||||
// A session can appear without a navigation (e.g. dialog-based sign-in).
|
||||
// Installed lazily because pinia is not active when router.ts evaluates.
|
||||
watch(
|
||||
() => useAuthStore().currentUser,
|
||||
() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
|
||||
*
|
||||
* The desktop app opens the browser with an opaque one-time code and polls
|
||||
* the cloud backend; redeeming the code from a signed-in browser session,
|
||||
* with the user's approval, releases a one-time custom token to that poll
|
||||
* and signs the desktop app in. The preserved-query tracker (configured in
|
||||
* router.ts) strips the code from the URL at capture time, so the stash is
|
||||
* the only place it lives.
|
||||
*/
|
||||
export function installDesktopLoginRedemption(router: Router): void {
|
||||
router.afterEach(() => {
|
||||
installAuthWatcherOnce()
|
||||
void redeemPendingDesktopLoginCode()
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden transition-[height] duration-300 ease-out"
|
||||
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
|
||||
:style="animatedHeightStyle"
|
||||
>
|
||||
<div ref="questionContent" class="relative">
|
||||
|
||||
@@ -5,5 +5,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth',
|
||||
PRICING: 'pricing'
|
||||
PRICING: 'pricing',
|
||||
DESKTOP_LOGIN: 'desktop_login'
|
||||
} as const
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUserStore } from '@/stores/userStore'
|
||||
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
|
||||
@@ -118,6 +119,11 @@ installPreservedQueryTracker(router, [
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
|
||||
keys: ['pricing']
|
||||
},
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
|
||||
keys: ['desktop_login_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
])
|
||||
|
||||
@@ -249,6 +255,8 @@ if (isCloud) {
|
||||
// User is logged in and accessing protected route
|
||||
return next()
|
||||
})
|
||||
|
||||
installDesktopLoginRedemption(router)
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user