mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 20:51:58 +00:00
Compare commits
14 Commits
fix/covera
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4059e0ac9d | ||
|
|
454f00016e | ||
|
|
7abd9d12c8 | ||
|
|
dd9cb42fa1 | ||
|
|
ccd19d8695 | ||
|
|
809fba7b36 | ||
|
|
df2ae6f2d0 | ||
|
|
3c7781190a | ||
|
|
167a1e6a0c | ||
|
|
e4e1546458 | ||
|
|
c1954028d1 | ||
|
|
5cad2c952b | ||
|
|
e356addeb6 | ||
|
|
e831daae59 |
23
.github/actions/ashby-pull/action.yaml
vendored
Normal file
23
.github/actions/ashby-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Ashby Pull
|
||||||
|
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
|
||||||
|
inputs:
|
||||||
|
api_key:
|
||||||
|
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
|
||||||
|
required: true
|
||||||
|
job_board_name:
|
||||||
|
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||||
|
|
||||||
|
- name: Setup frontend
|
||||||
|
uses: ./.github/actions/setup-frontend
|
||||||
|
|
||||||
|
- name: Refresh Ashby snapshot
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
|
||||||
|
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
|
||||||
|
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot
|
||||||
@@ -52,6 +52,9 @@ jobs:
|
|||||||
run: vercel pull --yes --environment=preview
|
run: vercel pull --yes --environment=preview
|
||||||
|
|
||||||
- name: Build project artifacts
|
- name: Build project artifacts
|
||||||
|
env:
|
||||||
|
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||||
|
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||||
run: vercel build
|
run: vercel build
|
||||||
|
|
||||||
- name: Fetch head commit metadata
|
- name: Fetch head commit metadata
|
||||||
@@ -146,6 +149,9 @@ jobs:
|
|||||||
run: vercel pull --yes --environment=production
|
run: vercel pull --yes --environment=production
|
||||||
|
|
||||||
- name: Build project artifacts
|
- name: Build project artifacts
|
||||||
|
env:
|
||||||
|
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||||
|
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||||
run: vercel build --prod
|
run: vercel build --prod
|
||||||
|
|
||||||
- name: Deploy project artifacts to Vercel
|
- name: Deploy project artifacts to Vercel
|
||||||
|
|||||||
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -36,4 +36,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-frontend
|
uses: ./.github/actions/setup-frontend
|
||||||
|
|
||||||
- name: Build website
|
- name: Build website
|
||||||
|
env:
|
||||||
|
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||||
|
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||||
run: pnpm --filter @comfyorg/website build
|
run: pnpm --filter @comfyorg/website build
|
||||||
|
|||||||
59
.github/workflows/release-website.yaml
vendored
Normal file
59
.github/workflows/release-website.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||||
|
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||||
|
# deploy via ci-vercel-website-preview.yaml.
|
||||||
|
name: 'Release: Website'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-website
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh-snapshot:
|
||||||
|
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Refresh Ashby snapshot
|
||||||
|
uses: ./.github/actions/ashby-pull
|
||||||
|
with:
|
||||||
|
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||||
|
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PR_GH_TOKEN }}
|
||||||
|
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||||
|
title: 'chore(website): refresh Ashby roles snapshot'
|
||||||
|
body: |
|
||||||
|
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||||
|
from the Ashby job board API.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. `Release: Website` workflow ran (manual trigger).
|
||||||
|
2. This PR opens with the regenerated snapshot.
|
||||||
|
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||||
|
4. Merging to `main` triggers the production Vercel deploy.
|
||||||
|
|
||||||
|
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||||
|
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||||
|
committed snapshot.
|
||||||
|
|
||||||
|
Triggered by workflow run `${{ github.run_id }}`.
|
||||||
|
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||||
|
base: main
|
||||||
|
labels: |
|
||||||
|
Release:Website
|
||||||
|
delete-branch: true
|
||||||
58
apps/website/public/llms.txt
Normal file
58
apps/website/public/llms.txt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Comfy
|
||||||
|
|
||||||
|
> Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Built around ComfyUI — the open-source node-graph runtime with 60,000+ community nodes and thousands of shared workflows — Comfy ships as a free local app, a managed cloud, an API, and an enterprise platform.
|
||||||
|
|
||||||
|
The Comfy ecosystem spans four surfaces:
|
||||||
|
|
||||||
|
- **ComfyUI (local)** — the open-source node-graph runtime that runs models on your own hardware.
|
||||||
|
- **Comfy Cloud** — managed ComfyUI in the browser, with hosted models and storage.
|
||||||
|
- **Comfy API** — a REST API for triggering workflows from your own apps and pipelines.
|
||||||
|
- **Comfy Enterprise** — single-tenant deployments, BYO keys, data ownership, and orchestration for teams.
|
||||||
|
|
||||||
|
Studios building with Comfy include Series Entertainment, Moment Factory, Open Story Movement, and Ubisoft (La Forge). Use cases concentrate in VFX & animation, advertising & creative studios, gaming, and eCommerce/fashion.
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
- [Homepage](https://comfy.org/): Overview of Comfy and the four product surfaces (Local, Cloud, API, Enterprise).
|
||||||
|
- [Download Comfy (Local)](https://comfy.org/download/): Free desktop app for macOS, Windows, and Linux — runs ComfyUI on your own GPU.
|
||||||
|
- [Comfy Cloud](https://comfy.org/cloud/): Managed ComfyUI in the browser with hosted models and storage; no local install required.
|
||||||
|
- [Comfy Cloud Pricing](https://comfy.org/cloud/pricing/): Plans and per-credit pricing for individuals and teams using Comfy Cloud.
|
||||||
|
- [Comfy API](https://comfy.org/api/): REST API for triggering ComfyUI workflows programmatically from external apps.
|
||||||
|
- [Comfy Enterprise](https://comfy.org/cloud/enterprise/): Single-tenant ComfyUI deployments with BYO keys, orchestration, and data-ownership guarantees.
|
||||||
|
|
||||||
|
## Workflows and Gallery
|
||||||
|
|
||||||
|
- [Workflow Gallery](https://comfy.org/gallery/): Curated showcase of ComfyUI outputs — images, video, and 3D — produced by the community.
|
||||||
|
- [Community Workflows](https://www.comfy.org/workflows/): Browseable library of community-shared ComfyUI workflows you can load and remix.
|
||||||
|
|
||||||
|
## Customers and Case Studies
|
||||||
|
|
||||||
|
- [Customer Stories](https://comfy.org/customers/): Index of named customers and how they use ComfyUI in production.
|
||||||
|
- [Series Entertainment](https://comfy.org/customers/series-entertainment/): How Series Entertainment rebuilt game and video production around ComfyUI.
|
||||||
|
- [Moment Factory](https://comfy.org/customers/moment-factory/): Architectural-scale 3D projection mapping reimagined with ComfyUI at Moment Factory.
|
||||||
|
- [Ubisoft — Chord](https://comfy.org/customers/ubisoft-chord/): Ubisoft La Forge open-sourcing the Chord model and its ComfyUI integration.
|
||||||
|
- [Open Story Movement](https://comfy.org/customers/open-story-movement/): How an open-source movement around AI storytelling builds on ComfyUI.
|
||||||
|
|
||||||
|
## Developers and Documentation
|
||||||
|
|
||||||
|
- [ComfyUI Docs](https://docs.comfy.org/): Official documentation for installing, configuring, and extending ComfyUI.
|
||||||
|
- [ComfyUI on GitHub](https://github.com/comfyanonymous/ComfyUI): Source repository for the open-source ComfyUI runtime.
|
||||||
|
- [Comfy-Org on GitHub](https://github.com/Comfy-Org): Organization-wide repositories — frontend, registry, manager, docs, and tooling.
|
||||||
|
- [Comfy Registry](https://registry.comfy.org/): Public registry of ComfyUI custom nodes and extensions, with versioning and search.
|
||||||
|
|
||||||
|
## Company
|
||||||
|
|
||||||
|
- [About Comfy](https://comfy.org/about/): Company background, mission, and the team behind ComfyUI.
|
||||||
|
- [Careers](https://comfy.org/careers/): Open roles across engineering, design, product, and go-to-market.
|
||||||
|
- [Contact](https://comfy.org/contact/): Sales, partnership, and general contact form.
|
||||||
|
- [Blog](https://blog.comfy.org/): Product announcements, technical deep-dives, and customer stories.
|
||||||
|
- [Privacy Policy](https://comfy.org/privacy-policy/): How Comfy collects, uses, and protects personal information.
|
||||||
|
- [Terms of Service](https://comfy.org/terms-of-service/): Terms governing use of ComfyUI and related Comfy services.
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [简体中文 / Chinese homepage](https://comfy.org/zh-CN/): Simplified Chinese localization of the main site.
|
||||||
|
- [Series Entertainment — long-form case study](https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui): Extended write-up of the Series Entertainment deployment.
|
||||||
|
- [Moment Factory — long-form case study](https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping): Extended write-up of Moment Factory's projection-mapping pipeline.
|
||||||
|
- [Ubisoft Chord announcement (blog)](https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model): Original blog post announcing Ubisoft's open-source Chord model.
|
||||||
|
- [Open-source storytelling (blog)](https://blog.comfy.org/p/how-open-source-is-fueling-the-open): Blog post on how open source is fueling the Open Story Movement.
|
||||||
@@ -1,4 +1,33 @@
|
|||||||
User-agent: *
|
# robots.txt for comfy.org
|
||||||
Allow: /
|
# Open to all crawlers — including AI/LLM bots — for maximum visibility
|
||||||
|
# in AI-powered search, chat-based answer engines, and traditional search.
|
||||||
|
# Granular UAs are listed explicitly to signal intent; rules are shared
|
||||||
|
# via stacked user-agent records (RFC 9309 §2.2).
|
||||||
|
|
||||||
Sitemap: https://comfy.org/sitemap-0.xml
|
User-agent: *
|
||||||
|
User-agent: Googlebot
|
||||||
|
User-agent: Bingbot
|
||||||
|
User-agent: DuckDuckBot
|
||||||
|
User-agent: GPTBot
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
User-agent: Google-Extended
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
User-agent: Claude-Web
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
User-agent: Perplexity-User
|
||||||
|
User-agent: Applebot
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
User-agent: Bytespider
|
||||||
|
User-agent: Amazonbot
|
||||||
|
User-agent: CCBot
|
||||||
|
User-agent: Meta-ExternalAgent
|
||||||
|
User-agent: Meta-ExternalFetcher
|
||||||
|
User-agent: Diffbot
|
||||||
|
Allow: /
|
||||||
|
Disallow: /_astro/
|
||||||
|
Disallow: /_website/
|
||||||
|
Disallow: /_vercel/
|
||||||
|
|
||||||
|
Sitemap: https://comfy.org/sitemap-index.xml
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Locale } from '../../i18n/translations'
|
import type { Locale } from '../../i18n/translations'
|
||||||
|
import { externalLinks } from '../../config/routes'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
|
import BrandButton from '../common/BrandButton.vue'
|
||||||
|
|
||||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||||
</script>
|
</script>
|
||||||
@@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
|||||||
>
|
>
|
||||||
{{ t('hero.subtitle', locale) }}
|
{{ t('hero.subtitle', locale) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<BrandButton
|
||||||
|
:href="externalLinks.workflows"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
|
||||||
|
>
|
||||||
|
{{ t('hero.runFirstWorkflow', locale) }}
|
||||||
|
</BrandButton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [
|
|||||||
detailPrefix: 'customers.detail.ubisoft-chord',
|
detailPrefix: 'customers.detail.ubisoft-chord',
|
||||||
readMoreHref:
|
readMoreHref:
|
||||||
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
|
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'groove-jones',
|
||||||
|
image:
|
||||||
|
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
|
||||||
|
category: 'customers.story.groove-jones.category',
|
||||||
|
title: 'customers.story.groove-jones.title',
|
||||||
|
body: 'customers.story.groove-jones.body',
|
||||||
|
detailPrefix: 'customers.detail.groove-jones'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
{
|
{
|
||||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||||
"departments": [
|
"departments": [
|
||||||
{
|
{
|
||||||
"name": "DESIGN",
|
"name": "DESIGN",
|
||||||
"key": "design",
|
"key": "design",
|
||||||
"roles": [
|
"roles": [
|
||||||
{
|
|
||||||
"id": "4c5d6afb78652df7",
|
|
||||||
"title": "Freelance Motion Designer",
|
|
||||||
"department": "Design",
|
|
||||||
"location": "San Francisco",
|
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0f5256cf302e552b",
|
|
||||||
"title": "Creative Artist",
|
|
||||||
"department": "Design",
|
|
||||||
"location": "San Francisco",
|
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "e915f2c78b17f93b",
|
"id": "e915f2c78b17f93b",
|
||||||
"title": "Senior Product Designer",
|
"title": "Senior Product Designer",
|
||||||
@@ -33,13 +19,6 @@
|
|||||||
"location": "San Francisco",
|
"location": "San Francisco",
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "5746486d87874937",
|
|
||||||
"title": "Graphic Designer",
|
|
||||||
"department": "Design",
|
|
||||||
"location": "San Francisco",
|
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "547b6ba622c800a5",
|
"id": "547b6ba622c800a5",
|
||||||
"title": "Senior Product Designer - Craft",
|
"title": "Senior Product Designer - Craft",
|
||||||
@@ -115,6 +94,13 @@
|
|||||||
"department": "Engineering",
|
"department": "Engineering",
|
||||||
"location": "San Francisco",
|
"location": "San Francisco",
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2eb53e8943cc9396",
|
||||||
|
"title": "Growth Engineer",
|
||||||
|
"department": "Engineering",
|
||||||
|
"location": "San Francisco",
|
||||||
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -122,6 +108,27 @@
|
|||||||
"name": "MARKETING",
|
"name": "MARKETING",
|
||||||
"key": "marketing",
|
"key": "marketing",
|
||||||
"roles": [
|
"roles": [
|
||||||
|
{
|
||||||
|
"id": "4c5d6afb78652df7",
|
||||||
|
"title": "Freelance Motion Designer",
|
||||||
|
"department": "Marketing",
|
||||||
|
"location": "San Francisco",
|
||||||
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0f5256cf302e552b",
|
||||||
|
"title": "Creative Artist",
|
||||||
|
"department": "Marketing",
|
||||||
|
"location": "San Francisco",
|
||||||
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5746486d87874937",
|
||||||
|
"title": "Graphic Designer",
|
||||||
|
"department": "Marketing",
|
||||||
|
"location": "San Francisco",
|
||||||
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "b5803a0d4785d406",
|
"id": "b5803a0d4785d406",
|
||||||
"title": "Lifecycle Growth Marketer",
|
"title": "Lifecycle Growth Marketer",
|
||||||
@@ -144,7 +151,7 @@
|
|||||||
"roles": [
|
"roles": [
|
||||||
{
|
{
|
||||||
"id": "ec68ae44dd5943c9",
|
"id": "ec68ae44dd5943c9",
|
||||||
"title": "Senior Technical Recruiter",
|
"title": "Talent Lead",
|
||||||
"department": "Operations",
|
"department": "Operations",
|
||||||
"location": "San Francisco",
|
"location": "San Francisco",
|
||||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const translations = {
|
|||||||
'zh-CN':
|
'zh-CN':
|
||||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||||
},
|
},
|
||||||
|
'hero.runFirstWorkflow': {
|
||||||
|
en: 'Run your first workflow',
|
||||||
|
'zh-CN': '运行你的第一个工作流'
|
||||||
|
},
|
||||||
|
|
||||||
// ProductShowcaseSection
|
// ProductShowcaseSection
|
||||||
'showcase.subtitle1': {
|
'showcase.subtitle1': {
|
||||||
@@ -2243,6 +2247,20 @@ const translations = {
|
|||||||
'zh-CN':
|
'zh-CN':
|
||||||
'育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。'
|
'育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。'
|
||||||
},
|
},
|
||||||
|
'customers.story.groove-jones.category': {
|
||||||
|
en: 'CASE STUDY',
|
||||||
|
'zh-CN': '案例研究'
|
||||||
|
},
|
||||||
|
'customers.story.groove-jones.title': {
|
||||||
|
en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy",
|
||||||
|
'zh-CN':
|
||||||
|
"Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
|
||||||
|
},
|
||||||
|
'customers.story.groove-jones.body': {
|
||||||
|
en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.',
|
||||||
|
'zh-CN':
|
||||||
|
'达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。'
|
||||||
|
},
|
||||||
'customers.story.readMore': {
|
'customers.story.readMore': {
|
||||||
en: 'READ MORE ON THIS TOPIC',
|
en: 'READ MORE ON THIS TOPIC',
|
||||||
'zh-CN': '阅读更多相关内容'
|
'zh-CN': '阅读更多相关内容'
|
||||||
@@ -3276,6 +3294,227 @@ const translations = {
|
|||||||
'zh-CN': 'ComfyUI 博客'
|
'zh-CN': 'ComfyUI 博客'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Customer Detail: Groove Jones
|
||||||
|
// Topic 1: Intro
|
||||||
|
'customers.detail.groove-jones.topic-1.label': {
|
||||||
|
en: 'INTRO',
|
||||||
|
'zh-CN': '简介'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-1.block.0': {
|
||||||
|
en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.',
|
||||||
|
'zh-CN':
|
||||||
|
'位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-1.block.1': {
|
||||||
|
en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.',
|
||||||
|
'zh-CN':
|
||||||
|
'在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。'
|
||||||
|
},
|
||||||
|
// Topic 2: The Output
|
||||||
|
'customers.detail.groove-jones.topic-2.label': {
|
||||||
|
en: 'THE OUTPUT',
|
||||||
|
'zh-CN': '交付成果'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-2.title': {
|
||||||
|
en: 'The Output Groove Jones Achieved Using Comfy',
|
||||||
|
'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-2.block.0': {
|
||||||
|
en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production',
|
||||||
|
'zh-CN':
|
||||||
|
'在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动\n超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代,不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖'
|
||||||
|
},
|
||||||
|
// Topic 3: The Problem
|
||||||
|
'customers.detail.groove-jones.topic-3.label': {
|
||||||
|
en: 'THE PROBLEM',
|
||||||
|
'zh-CN': '挑战'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-3.title': {
|
||||||
|
en: 'The Problem Groove Jones Was Trying to Solve',
|
||||||
|
'zh-CN': 'Groove Jones 试图解决的问题'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-3.block.0': {
|
||||||
|
en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.',
|
||||||
|
'zh-CN':
|
||||||
|
'按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。'
|
||||||
|
},
|
||||||
|
// Topic 4: How Comfy Solved the Problem
|
||||||
|
'customers.detail.groove-jones.topic-4.label': {
|
||||||
|
en: 'HOW COMFY SOLVED THE PROBLEM',
|
||||||
|
'zh-CN': 'Comfy 如何解决问题'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-4.title': {
|
||||||
|
en: 'How Groove Jones Used Comfy to Solve the Problem',
|
||||||
|
'zh-CN': 'Groove Jones 如何用 Comfy 解决问题'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-4.block.0': {
|
||||||
|
en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.',
|
||||||
|
'zh-CN':
|
||||||
|
'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-4.block.1.text': {
|
||||||
|
en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.',
|
||||||
|
'zh-CN':
|
||||||
|
'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-4.block.1.name': {
|
||||||
|
en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones',
|
||||||
|
'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监'
|
||||||
|
},
|
||||||
|
// Topic 5: Brand-Trained LoRAs
|
||||||
|
'customers.detail.groove-jones.topic-5.label': {
|
||||||
|
en: 'BRAND-TRAINED LORAS',
|
||||||
|
'zh-CN': '品牌定制 LORA'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-5.title': {
|
||||||
|
en: 'Brand-Trained LoRAs for Hero Assets',
|
||||||
|
'zh-CN': '为主视觉资产定制的品牌 LoRA'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-5.block.0': {
|
||||||
|
en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.',
|
||||||
|
'zh-CN':
|
||||||
|
'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-5.block.1.src': {
|
||||||
|
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp',
|
||||||
|
'zh-CN':
|
||||||
|
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-5.block.1.alt': {
|
||||||
|
en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs',
|
||||||
|
'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-5.block.1.caption': {
|
||||||
|
en: 'Brand-accurate NFL team colorways generated through custom LoRAs.',
|
||||||
|
'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。'
|
||||||
|
},
|
||||||
|
// Topic 6: Multi-Model Orchestration
|
||||||
|
'customers.detail.groove-jones.topic-6.label': {
|
||||||
|
en: 'MULTI-MODEL ORCHESTRATION',
|
||||||
|
'zh-CN': '多模型编排'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-6.title': {
|
||||||
|
en: 'Multi-Model Orchestration in a Single Graph',
|
||||||
|
'zh-CN': '单张图内的多模型编排'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-6.block.0': {
|
||||||
|
en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.',
|
||||||
|
'zh-CN':
|
||||||
|
'这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-6.block.1.text': {
|
||||||
|
en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.',
|
||||||
|
'zh-CN':
|
||||||
|
'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-6.block.1.name': {
|
||||||
|
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||||
|
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||||
|
},
|
||||||
|
// Topic 7: The Pipeline
|
||||||
|
'customers.detail.groove-jones.topic-7.label': {
|
||||||
|
en: 'THE PIPELINE',
|
||||||
|
'zh-CN': '流水线'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.title': {
|
||||||
|
en: 'Storyboards to Previz to Final Shot in One Pipeline',
|
||||||
|
'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.0': {
|
||||||
|
en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.',
|
||||||
|
'zh-CN':
|
||||||
|
'工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.1.src': {
|
||||||
|
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp',
|
||||||
|
'zh-CN':
|
||||||
|
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.1.alt': {
|
||||||
|
en: 'Storyboard grid for the Crocs x NFL holiday campaign',
|
||||||
|
'zh-CN': 'Crocs x NFL 节日营销的故事板网格'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.1.caption': {
|
||||||
|
en: 'Grayscale storyboards used to lock narrative beats before generation.',
|
||||||
|
'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.2.src': {
|
||||||
|
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp',
|
||||||
|
'zh-CN':
|
||||||
|
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.2.alt': {
|
||||||
|
en: 'Composition progression from blocking to mid-render to final shot',
|
||||||
|
'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-7.block.2.caption': {
|
||||||
|
en: 'Composition progression: wireframe blocking, mid-render, and final shot.',
|
||||||
|
'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。'
|
||||||
|
},
|
||||||
|
// Topic 8: Version Control
|
||||||
|
'customers.detail.groove-jones.topic-8.label': {
|
||||||
|
en: 'VERSION CONTROL',
|
||||||
|
'zh-CN': '版本管理'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-8.title': {
|
||||||
|
en: 'Workflow Files as Version Control',
|
||||||
|
'zh-CN': '把工作流文件当作版本管理'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-8.block.0': {
|
||||||
|
en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.',
|
||||||
|
'zh-CN':
|
||||||
|
'每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。'
|
||||||
|
},
|
||||||
|
// Topic 9: Finishing in Nuke
|
||||||
|
'customers.detail.groove-jones.topic-9.label': {
|
||||||
|
en: 'FINISHING IN NUKE',
|
||||||
|
'zh-CN': 'Nuke 终修'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-9.title': {
|
||||||
|
en: 'Finishing in Nuke',
|
||||||
|
'zh-CN': '在 Nuke 中完成终修'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-9.block.0': {
|
||||||
|
en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.',
|
||||||
|
'zh-CN':
|
||||||
|
'生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。'
|
||||||
|
},
|
||||||
|
// Topic 10: The Takeaway
|
||||||
|
'customers.detail.groove-jones.topic-10.label': {
|
||||||
|
en: 'THE TAKEAWAY',
|
||||||
|
'zh-CN': '总结'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.title': {
|
||||||
|
en: 'Conclusion',
|
||||||
|
'zh-CN': '结语'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.0': {
|
||||||
|
en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.',
|
||||||
|
'zh-CN':
|
||||||
|
'通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.1.text': {
|
||||||
|
en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.',
|
||||||
|
'zh-CN':
|
||||||
|
'在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.1.name': {
|
||||||
|
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||||
|
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.2.label': {
|
||||||
|
en: 'GROOVE JONES CONTRIBUTORS',
|
||||||
|
'zh-CN': 'GROOVE JONES 贡献者'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.2.name': {
|
||||||
|
en: 'TBD',
|
||||||
|
'zh-CN': '待补充'
|
||||||
|
},
|
||||||
|
'customers.detail.groove-jones.topic-10.block.2.role': {
|
||||||
|
en: 'TBD',
|
||||||
|
'zh-CN': '待补充'
|
||||||
|
},
|
||||||
|
|
||||||
// Contact – FormSection
|
// Contact – FormSection
|
||||||
'contact.form.badge': {
|
'contact.form.badge': {
|
||||||
en: 'CONTACT SALES',
|
en: 'CONTACT SALES',
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
"github": {
|
"github": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"has": [
|
||||||
|
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||||
|
],
|
||||||
|
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
"redirects": [
|
"redirects": [
|
||||||
{
|
{
|
||||||
"source": "/pricing",
|
"source": "/pricing",
|
||||||
|
|||||||
@@ -119,7 +119,15 @@
|
|||||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||||
],
|
],
|
||||||
"properties": {},
|
"properties": {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "v1-5-pruned-emaonly-fp16.safetensors",
|
||||||
|
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
|
||||||
|
"directory": "checkpoints"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ export const TestIds = {
|
|||||||
queue: {
|
queue: {
|
||||||
overlayToggle: 'queue-overlay-toggle',
|
overlayToggle: 'queue-overlay-toggle',
|
||||||
clearHistoryAction: 'clear-history-action',
|
clearHistoryAction: 'clear-history-action',
|
||||||
jobAssetsList: 'job-assets-list'
|
jobAssetsList: 'job-assets-list',
|
||||||
|
notificationBanner: 'queue-notification-banner'
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
imageLoadError: 'error-loading-image',
|
imageLoadError: 'error-loading-image',
|
||||||
|
|||||||
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
|
// Mirrors BANNER_DISMISS_DELAY_MS in src/composables/queue/useQueueNotificationBanners.ts.
|
||||||
|
// Duplicated here to avoid pulling production source (and its litegraph
|
||||||
|
// transitive deps) into the Playwright TS loader.
|
||||||
|
const BANNER_DISMISS_DELAY_MS = 4000
|
||||||
|
const BANNER_ASSERT_TIMEOUT_MS = BANNER_DISMISS_DELAY_MS + 2000
|
||||||
|
|
||||||
|
const REQUEST_ID_PRIMARY = 1
|
||||||
|
const REQUEST_ID_SECONDARY = 2
|
||||||
|
const REQUEST_ID_MISMATCH = 999
|
||||||
|
|
||||||
|
let nextRequestId = 1000
|
||||||
|
const newRequestId = () => nextRequestId++
|
||||||
|
|
||||||
|
function bannerLocator(page: Page) {
|
||||||
|
return page.getByTestId(TestIds.queue.notificationBanner)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DispatchOpts = { batchCount?: number; requestId?: number }
|
||||||
|
|
||||||
|
function dispatchPromptQueueing(page: Page, opts: DispatchOpts = {}) {
|
||||||
|
return page.evaluate(
|
||||||
|
([batchCount, requestId]) => {
|
||||||
|
window.app!.api.dispatchCustomEvent('promptQueueing', {
|
||||||
|
batchCount,
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchPromptQueued(page: Page, opts: DispatchOpts = {}) {
|
||||||
|
return page.evaluate(
|
||||||
|
([batchCount, requestId]) => {
|
||||||
|
window.app!.api.dispatchCustomEvent('promptQueued', {
|
||||||
|
number: 0,
|
||||||
|
batchCount,
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Queue notification banners', { tag: ['@ui'] }, () => {
|
||||||
|
test.describe('Queuing lifecycle', () => {
|
||||||
|
test('promptQueueing event shows a queueing banner', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueueing(comfyPage.page)
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toBeVisible()
|
||||||
|
await expect(banner).toContainText('queuing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('promptQueued upgrades a pending banner to queued', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueueing(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_PRIMARY
|
||||||
|
})
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toContainText('queuing')
|
||||||
|
|
||||||
|
await dispatchPromptQueued(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_PRIMARY
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(banner).toContainText('queued')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('promptQueued with batch count > 1 shows plural text', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueued(comfyPage.page, { batchCount: 3 })
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toBeVisible()
|
||||||
|
await expect(banner).toContainText('3')
|
||||||
|
await expect(banner).toContainText('jobs added to queue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('promptQueued with mismatched requestId enqueues a separate queued banner', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueueing(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_PRIMARY
|
||||||
|
})
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toContainText('queuing')
|
||||||
|
|
||||||
|
await dispatchPromptQueued(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_MISMATCH
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pending banner is not upgraded — still shows "queuing".
|
||||||
|
await expect(banner).toContainText('queuing')
|
||||||
|
|
||||||
|
// After the pending banner auto-dismisses, the queued banner appears.
|
||||||
|
await expect(banner).toContainText('queued', {
|
||||||
|
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Auto-dismiss', () => {
|
||||||
|
test('Banner auto-dismisses after timeout', async ({ comfyPage }) => {
|
||||||
|
await dispatchPromptQueued(comfyPage.page)
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toBeVisible()
|
||||||
|
await expect(banner).toBeHidden({ timeout: BANNER_ASSERT_TIMEOUT_MS })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Notification queue (FIFO)', () => {
|
||||||
|
test('Second notification shows after first auto-dismisses', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueued(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_PRIMARY
|
||||||
|
})
|
||||||
|
await dispatchPromptQueued(comfyPage.page, {
|
||||||
|
batchCount: 2,
|
||||||
|
requestId: REQUEST_ID_SECONDARY
|
||||||
|
})
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toContainText('Job queued')
|
||||||
|
await expect(banner).toContainText('2 jobs added to queue', {
|
||||||
|
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Direct queued event (no pending predecessor)', () => {
|
||||||
|
test('promptQueued without prior queueing shows queued banner directly', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await dispatchPromptQueued(comfyPage.page, {
|
||||||
|
batchCount: 1,
|
||||||
|
requestId: REQUEST_ID_PRIMARY
|
||||||
|
})
|
||||||
|
|
||||||
|
const banner = bannerLocator(comfyPage.page)
|
||||||
|
await expect(banner).toBeVisible()
|
||||||
|
await expect(banner).toContainText('queued')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"version": "1.44.15",
|
"version": "1.44.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Official front-end implementation of ComfyUI",
|
"description": "Official front-end implementation of ComfyUI",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
|
|||||||
177
scripts/generate-embedded-metadata-test-files.py
Normal file
177
scripts/generate-embedded-metadata-test-files.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate test fixture files for metadata parser tests.
|
||||||
|
|
||||||
|
Each fixture embeds the same workflow and prompt JSON, matching the
|
||||||
|
format the ComfyUI backend uses to write metadata.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
source ~/ComfyUI/.venv/bin/activate
|
||||||
|
python3 scripts/generate-embedded-metadata-test-files.py
|
||||||
|
|
||||||
|
Output: src/scripts/metadata/__fixtures__/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import av
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||||
|
|
||||||
|
WORKFLOW = {
|
||||||
|
'nodes': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'type': 'KSampler',
|
||||||
|
'pos': [100, 100],
|
||||||
|
'size': [200, 200],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||||
|
|
||||||
|
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||||
|
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||||
|
|
||||||
|
|
||||||
|
def out(name: str) -> str:
|
||||||
|
return os.path.join(FIXTURES_DIR, name)
|
||||||
|
|
||||||
|
|
||||||
|
def report(name: str):
|
||||||
|
size = os.path.getsize(out(name))
|
||||||
|
print(f' {name} ({size} bytes)')
|
||||||
|
|
||||||
|
|
||||||
|
def make_1x1_image() -> Image.Image:
|
||||||
|
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def build_exif_bytes() -> bytes:
|
||||||
|
"""Build EXIF bytes matching the backend's tag assignments.
|
||||||
|
|
||||||
|
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||||
|
"""
|
||||||
|
img = make_1x1_image()
|
||||||
|
exif = img.getexif()
|
||||||
|
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||||
|
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||||
|
return exif.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def inject_exif_prefix_in_webp(path: str):
|
||||||
|
"""Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file.
|
||||||
|
|
||||||
|
PIL always strips this prefix, so we re-inject it to test that code path.
|
||||||
|
"""
|
||||||
|
data = bytearray(open(path, 'rb').read())
|
||||||
|
off = 12
|
||||||
|
while off < len(data):
|
||||||
|
chunk_type = data[off:off + 4]
|
||||||
|
chunk_len = struct.unpack_from('<I', data, off + 4)[0]
|
||||||
|
if chunk_type == b'EXIF':
|
||||||
|
prefix = b'Exif\x00\x00'
|
||||||
|
data[off + 8:off + 8] = prefix
|
||||||
|
struct.pack_into('<I', data, off + 4, chunk_len + len(prefix))
|
||||||
|
riff_size = struct.unpack_from('<I', data, 4)[0]
|
||||||
|
struct.pack_into('<I', data, 4, riff_size + len(prefix))
|
||||||
|
break
|
||||||
|
off += 8 + chunk_len + (chunk_len % 2)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_av_fixture(
|
||||||
|
name: str,
|
||||||
|
fmt: str,
|
||||||
|
codec: str,
|
||||||
|
rate: int = 44100,
|
||||||
|
options: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||||
|
path = out(name)
|
||||||
|
container = av.open(path, mode='w', format=fmt, options=options or {})
|
||||||
|
stream = container.add_stream(codec, rate=rate)
|
||||||
|
stream.layout = 'mono'
|
||||||
|
|
||||||
|
container.metadata['prompt'] = PROMPT_JSON
|
||||||
|
container.metadata['workflow'] = WORKFLOW_JSON
|
||||||
|
|
||||||
|
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||||
|
samples = stream.codec_context.frame_size or 1024
|
||||||
|
frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples)
|
||||||
|
frame.rate = rate
|
||||||
|
frame.pts = 0
|
||||||
|
for packet in stream.encode(frame):
|
||||||
|
container.mux(packet)
|
||||||
|
for packet in stream.encode():
|
||||||
|
container.mux(packet)
|
||||||
|
container.close()
|
||||||
|
report(name)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_webp():
|
||||||
|
img = make_1x1_image()
|
||||||
|
exif = build_exif_bytes()
|
||||||
|
|
||||||
|
img.save(out('with_metadata.webp'), 'WEBP', exif=exif)
|
||||||
|
report('with_metadata.webp')
|
||||||
|
|
||||||
|
img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif)
|
||||||
|
inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp'))
|
||||||
|
report('with_metadata_exif_prefix.webp')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_avif():
|
||||||
|
img = make_1x1_image()
|
||||||
|
exif = build_exif_bytes()
|
||||||
|
img.save(out('with_metadata.avif'), 'AVIF', exif=exif)
|
||||||
|
report('with_metadata.avif')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_flac():
|
||||||
|
generate_av_fixture('with_metadata.flac', 'flac', 'flac')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_opus():
|
||||||
|
generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mp3():
|
||||||
|
generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mp4():
|
||||||
|
"""Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata."""
|
||||||
|
path = out('with_metadata.mp4')
|
||||||
|
subprocess.run([
|
||||||
|
'ffmpeg', '-y', '-loglevel', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||||
|
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||||
|
'-movflags', 'use_metadata_tags',
|
||||||
|
'-metadata', f'prompt={PROMPT_JSON}',
|
||||||
|
'-metadata', f'workflow={WORKFLOW_JSON}',
|
||||||
|
path,
|
||||||
|
], check=True)
|
||||||
|
report('with_metadata.mp4')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_webm():
|
||||||
|
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print('Generating fixtures...')
|
||||||
|
generate_webp()
|
||||||
|
generate_avif()
|
||||||
|
generate_flac()
|
||||||
|
generate_opus()
|
||||||
|
generate_mp3()
|
||||||
|
generate_mp4()
|
||||||
|
generate_webm()
|
||||||
|
print('Done.')
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
|
data-testid="queue-notification-banner"
|
||||||
>
|
>
|
||||||
<QueueNotificationBanner :notification="currentNotification" />
|
<QueueNotificationBanner :notification="currentNotification" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ function createMeshModel(name = 'TestModel'): THREE.Group {
|
|||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPointsModel(name = 'TestModel'): THREE.Group {
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
const material = new THREE.PointsMaterial({ color: 0xff0000 })
|
||||||
|
const points = new THREE.Points(geometry, material)
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.name = name
|
||||||
|
group.add(points)
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
describe('SceneModelManager', () => {
|
describe('SceneModelManager', () => {
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('initializes default state', () => {
|
it('initializes default state', () => {
|
||||||
@@ -311,6 +321,20 @@ describe('SceneModelManager', () => {
|
|||||||
expect(geoDispose).toHaveBeenCalled()
|
expect(geoDispose).toHaveBeenCalled()
|
||||||
expect(matDispose).toHaveBeenCalled()
|
expect(matDispose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('disposes points geometry and materials', async () => {
|
||||||
|
const { manager } = createManager()
|
||||||
|
const model = createPointsModel()
|
||||||
|
const points = model.children[0] as THREE.Points
|
||||||
|
const geoDispose = vi.spyOn(points.geometry, 'dispose')
|
||||||
|
const matDispose = vi.spyOn(points.material as THREE.Material, 'dispose')
|
||||||
|
|
||||||
|
await manager.setupModel(model)
|
||||||
|
manager.clearModel()
|
||||||
|
|
||||||
|
expect(geoDispose).toHaveBeenCalled()
|
||||||
|
expect(matDispose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
|||||||
this.scene.remove(obj)
|
this.scene.remove(obj)
|
||||||
|
|
||||||
obj.traverse((child) => {
|
obj.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
|
||||||
child.geometry?.dispose()
|
child.geometry?.dispose()
|
||||||
if (Array.isArray(child.material)) {
|
if (Array.isArray(child.material)) {
|
||||||
child.material.forEach((material) => material.dispose())
|
child.material.forEach((material) => material.dispose())
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟"
|
"wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "يرجى اختيار خيار.",
|
||||||
|
"describeAnswer": "يرجى وصف إجابتك.",
|
||||||
|
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
|
||||||
|
},
|
||||||
|
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
||||||
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "الهندسة المعمارية",
|
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
|
||||||
"education": "التعليم",
|
"api": "نقاط نهاية API لتشغيل مسارات العمل",
|
||||||
"film_tv_animation": "الأفلام والتلفزيون والرسوم المتحركة",
|
"apps": "تطبيقات مبسطة من مسارات العمل",
|
||||||
"fine_art": "الفنون الجميلة والرسوم التوضيحية",
|
"audio": "صوت / موسيقى",
|
||||||
"gaming": "الألعاب",
|
"custom_nodes": "عُقد مخصصة",
|
||||||
"marketing": "التسويق والإعلان",
|
"images": "صور",
|
||||||
|
"not_sure": "لست متأكدًا",
|
||||||
|
"videos": "فيديوهات",
|
||||||
|
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"conference": "مؤتمر أو فعالية",
|
||||||
|
"discord": "ديسكورد / مجتمع",
|
||||||
|
"friend": "صديق أو زميل",
|
||||||
|
"github": "GitHub",
|
||||||
|
"instagram": "إنستغرام",
|
||||||
|
"linkedin": "لينكدإن",
|
||||||
|
"newsletter": "نشرة بريدية أو مدونة",
|
||||||
"other": "أخرى",
|
"other": "أخرى",
|
||||||
"otherPlaceholder": "يرجى التحديد",
|
"reddit": "ريديت",
|
||||||
"product_design": "تصميم المنتجات والرسوم",
|
"search": "جوجل / بحث",
|
||||||
"software": "البرمجيات والتكنولوجيا"
|
"twitter": "تويتر / X",
|
||||||
|
"youtube": "يوتيوب"
|
||||||
},
|
},
|
||||||
"making": {
|
"usage": {
|
||||||
"3d": "الأصول ثلاثية الأبعاد",
|
"education": "تعليمي (طالب أو معلم)",
|
||||||
"audio": "الصوت / الموسيقى",
|
"personal": "استخدام شخصي",
|
||||||
"custom_nodes": "العُقد وسير العمل المخصصة",
|
"work": "عمل"
|
||||||
"images": "الصور",
|
|
||||||
"video": "الفيديو والرسوم المتحركة"
|
|
||||||
},
|
|
||||||
"purpose": {
|
|
||||||
"client": "العمل للعملاء (العمل الحر)",
|
|
||||||
"community": "مساهمات مجتمعية (عُقد، سير عمل، إلخ)",
|
|
||||||
"inhouse": "مكان عملي الخاص (داخلي)",
|
|
||||||
"personal": "مشاريع شخصية / هواية",
|
|
||||||
"research": "البحث الأكاديمي"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "نص بديل لأسئلة الاستبيان",
|
"placeholder": "نص بديل لأسئلة الاستبيان",
|
||||||
"questions": {
|
|
||||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
|
||||||
"industry": "ما هي صناعتك الأساسية؟",
|
|
||||||
"making": "ماذا تخطط لصنعه؟",
|
|
||||||
"purpose": "ما الاستخدام الأساسي الذي تخطط لـ ComfyUI من أجله؟"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||||
"industry": "ما هي صناعتك الأساسية؟",
|
"intent": "ماذا تريد أن تنشئ باستخدام ComfyUI؟",
|
||||||
"making": "ماذا تخطط لصنعه؟",
|
"source": "من أين سمعت عن ComfyUI؟",
|
||||||
"purpose": "ما الاستخدام الأساسي الذي ستستخدم ComfyUI من أجله؟"
|
"usage": "كيف تخطط لاستخدام ComfyUI؟"
|
||||||
},
|
},
|
||||||
"title": "استبيان السحابة"
|
"title": "استبيان السحابة"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
||||||
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
||||||
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||||
"cloudSurvey_steps_industry": "ما مجال عملك الأساسي؟",
|
"cloudSurvey_steps_intent": "ماذا تريد أن تنشئ باستخدام ComfyUI؟",
|
||||||
"cloudSurvey_steps_making": "ماذا تخطط لصنعه؟",
|
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
|
||||||
"cloudSurvey_steps_purpose": "ما الاستخدام الأساسي الذي تخطط لـ ComfyUI من أجله؟",
|
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
|
||||||
"cloudWaitlist_contactLink": "هنا",
|
"cloudWaitlist_contactLink": "هنا",
|
||||||
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "مون فالي ماري",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -1727,7 +1727,6 @@
|
|||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"model_specific": "model_specific",
|
"model_specific": "model_specific",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"Sora": "Sora",
|
"Sora": "Sora",
|
||||||
"cond pair": "cond pair",
|
"cond pair": "cond pair",
|
||||||
|
|||||||
@@ -11489,120 +11489,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"MoonvalleyImg2VideoNode": {
|
|
||||||
"display_name": "Moonvalley Marey Image to Video",
|
|
||||||
"description": "Moonvalley Marey Image to Video Node",
|
|
||||||
"inputs": {
|
|
||||||
"image": {
|
|
||||||
"name": "image",
|
|
||||||
"tooltip": "The reference image used to generate the video"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"name": "prompt"
|
|
||||||
},
|
|
||||||
"negative_prompt": {
|
|
||||||
"name": "negative_prompt",
|
|
||||||
"tooltip": "Negative prompt text"
|
|
||||||
},
|
|
||||||
"resolution": {
|
|
||||||
"name": "resolution",
|
|
||||||
"tooltip": "Resolution of the output video"
|
|
||||||
},
|
|
||||||
"prompt_adherence": {
|
|
||||||
"name": "prompt_adherence",
|
|
||||||
"tooltip": "Guidance scale for generation control"
|
|
||||||
},
|
|
||||||
"seed": {
|
|
||||||
"name": "seed",
|
|
||||||
"tooltip": "Random seed value"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"name": "steps",
|
|
||||||
"tooltip": "Number of denoising steps"
|
|
||||||
},
|
|
||||||
"control_after_generate": {
|
|
||||||
"name": "control after generate"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"outputs": {
|
|
||||||
"0": {
|
|
||||||
"tooltip": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"MoonvalleyTxt2VideoNode": {
|
|
||||||
"display_name": "Moonvalley Marey Text to Video",
|
|
||||||
"inputs": {
|
|
||||||
"prompt": {
|
|
||||||
"name": "prompt"
|
|
||||||
},
|
|
||||||
"negative_prompt": {
|
|
||||||
"name": "negative_prompt",
|
|
||||||
"tooltip": "Negative prompt text"
|
|
||||||
},
|
|
||||||
"resolution": {
|
|
||||||
"name": "resolution",
|
|
||||||
"tooltip": "Resolution of the output video"
|
|
||||||
},
|
|
||||||
"prompt_adherence": {
|
|
||||||
"name": "prompt_adherence",
|
|
||||||
"tooltip": "Guidance scale for generation control"
|
|
||||||
},
|
|
||||||
"seed": {
|
|
||||||
"name": "seed",
|
|
||||||
"tooltip": "Random seed value"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"name": "steps",
|
|
||||||
"tooltip": "Inference steps"
|
|
||||||
},
|
|
||||||
"control_after_generate": {
|
|
||||||
"name": "control after generate"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"outputs": {
|
|
||||||
"0": {
|
|
||||||
"tooltip": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"MoonvalleyVideo2VideoNode": {
|
|
||||||
"display_name": "Moonvalley Marey Video to Video",
|
|
||||||
"inputs": {
|
|
||||||
"prompt": {
|
|
||||||
"name": "prompt",
|
|
||||||
"tooltip": "Describes the video to generate"
|
|
||||||
},
|
|
||||||
"negative_prompt": {
|
|
||||||
"name": "negative_prompt",
|
|
||||||
"tooltip": "Negative prompt text"
|
|
||||||
},
|
|
||||||
"seed": {
|
|
||||||
"name": "seed",
|
|
||||||
"tooltip": "Random seed value"
|
|
||||||
},
|
|
||||||
"video": {
|
|
||||||
"name": "video",
|
|
||||||
"tooltip": "The reference video used to generate the output video. Must be at least 5 seconds long. Videos longer than 5s will be automatically trimmed. Only MP4 format supported."
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"name": "steps",
|
|
||||||
"tooltip": "Number of inference steps"
|
|
||||||
},
|
|
||||||
"control_type": {
|
|
||||||
"name": "control_type"
|
|
||||||
},
|
|
||||||
"motion_intensity": {
|
|
||||||
"name": "motion_intensity",
|
|
||||||
"tooltip": "Only used if control_type is 'Motion Transfer'"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"outputs": {
|
|
||||||
"0": {
|
|
||||||
"tooltip": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Morphology": {
|
"Morphology": {
|
||||||
"display_name": "ImageMorphology",
|
"display_name": "ImageMorphology",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "¿Prefieres ejecutar ComfyUI localmente?"
|
"wantToRun": "¿Prefieres ejecutar ComfyUI localmente?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"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": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
|
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
|
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
|
||||||
"starting": "Recién comenzando (siguiendo tutoriales)"
|
"starting": "Recién comenzando (siguiendo tutoriales)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "Arquitectura",
|
"3d_game": "Recursos 3D / recursos para juegos",
|
||||||
"education": "Educación",
|
"api": "Endpoints de API para ejecutar flujos de trabajo",
|
||||||
"film_tv_animation": "Cine, TV y animación",
|
"apps": "Apps simplificadas a partir de flujos de trabajo",
|
||||||
"fine_art": "Bellas artes e ilustración",
|
|
||||||
"gaming": "Juegos",
|
|
||||||
"marketing": "Marketing y publicidad",
|
|
||||||
"other": "Otro",
|
|
||||||
"otherPlaceholder": "Por favor, especifica",
|
|
||||||
"product_design": "Diseño de productos y gráfico",
|
|
||||||
"software": "Software y tecnología"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "Recursos 3D",
|
|
||||||
"audio": "Audio / música",
|
"audio": "Audio / música",
|
||||||
"custom_nodes": "Nodos y flujos de trabajo personalizados",
|
"custom_nodes": "Nodos personalizados",
|
||||||
"images": "Imágenes",
|
"images": "Imágenes",
|
||||||
"video": "Video y animación"
|
"not_sure": "No estoy seguro",
|
||||||
|
"videos": "Videos",
|
||||||
|
"workflows": "Flujos de trabajo o pipelines personalizados"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "Trabajo para clientes (freelance)",
|
"conference": "Conferencia o evento",
|
||||||
"community": "Contribuciones a la comunidad (nodos, flujos de trabajo, etc.)",
|
"discord": "Discord / comunidad",
|
||||||
"inhouse": "Mi propio lugar de trabajo (interno)",
|
"friend": "Amigo o colega",
|
||||||
"personal": "Proyectos personales / hobby",
|
"github": "GitHub",
|
||||||
"research": "Investigación académica"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "Newsletter o blog",
|
||||||
|
"other": "Otro",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / búsqueda",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "Educación (estudiante o docente)",
|
||||||
|
"personal": "Uso personal",
|
||||||
|
"work": "Trabajo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "Marcador de posición para preguntas de la encuesta",
|
"placeholder": "Marcador de posición para preguntas de la encuesta",
|
||||||
"questions": {
|
|
||||||
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
|
||||||
"industry": "¿Cuál es tu industria principal?",
|
|
||||||
"making": "¿Qué planeas crear?",
|
|
||||||
"purpose": "¿Para qué usarás principalmente ComfyUI?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||||
"industry": "¿Cuál es tu industria principal?",
|
"intent": "¿Qué quieres crear con ComfyUI?",
|
||||||
"making": "¿Qué planeas crear?",
|
"source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||||
"purpose": "¿Para qué usarás principalmente ComfyUI?"
|
"usage": "¿Cómo planeas usar ComfyUI?"
|
||||||
},
|
},
|
||||||
"title": "Encuesta en la Nube"
|
"title": "Encuesta en la Nube"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "comienza a crear en segundos",
|
"cloudStart_title": "comienza a crear en segundos",
|
||||||
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
|
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
|
||||||
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||||
"cloudSurvey_steps_industry": "¿Cuál es tu industria principal?",
|
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
|
||||||
"cloudSurvey_steps_making": "¿Qué planeas crear?",
|
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||||
"cloudSurvey_steps_purpose": "¿Para qué usarás principalmente ComfyUI?",
|
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
|
||||||
"cloudWaitlist_contactLink": "aquí",
|
"cloudWaitlist_contactLink": "aquí",
|
||||||
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
|
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "میخواهید ComfyUI را به صورت محلی اجرا کنید؟"
|
"wantToRun": "میخواهید ComfyUI را به صورت محلی اجرا کنید؟"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
|
||||||
|
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
|
||||||
|
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
|
||||||
|
},
|
||||||
|
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را شخصیسازی کنیم.",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "کاربر پیشرفته (جریانکارهای سفارشی)",
|
"advanced": "کاربر پیشرفته (جریانکارهای سفارشی)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "جدید در ComfyUI (تا کنون استفاده نکردهام)",
|
"new": "جدید در ComfyUI (تا کنون استفاده نکردهام)",
|
||||||
"starting": "تازه شروع کردهام (در حال دنبال کردن آموزشها)"
|
"starting": "تازه شروع کردهام (در حال دنبال کردن آموزشها)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "معماری",
|
"3d_game": "دارایی سهبعدی / دارایی بازی",
|
||||||
"education": "آموزش",
|
"api": "API endpoint برای اجرای workflow",
|
||||||
"film_tv_animation": "فیلم، تلویزیون و انیمیشن",
|
"apps": "اپلیکیشن سادهشده از workflow",
|
||||||
"fine_art": "هنرهای زیبا و تصویرسازی",
|
|
||||||
"gaming": "بازیسازی",
|
|
||||||
"marketing": "بازاریابی و تبلیغات",
|
|
||||||
"other": "سایر",
|
|
||||||
"otherPlaceholder": "لطفاً مشخص کنید",
|
|
||||||
"product_design": "طراحی محصول و گرافیک",
|
|
||||||
"software": "نرمافزار و فناوری"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "داراییهای سهبعدی",
|
|
||||||
"audio": "صدا / موسیقی",
|
"audio": "صدا / موسیقی",
|
||||||
"custom_nodes": "nodeها و workflowهای سفارشی",
|
"custom_nodes": "node سفارشی",
|
||||||
"images": "تصاویر",
|
"images": "تصویر",
|
||||||
"video": "ویدیو و انیمیشن"
|
"not_sure": "مطمئن نیستم",
|
||||||
|
"videos": "ویدیو",
|
||||||
|
"workflows": "workflow یا pipeline سفارشی"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "کار برای مشتری (فریلنس)",
|
"conference": "کنفرانس یا رویداد",
|
||||||
"community": "مشارکت در جامعه (nodeها، workflowها و غیره)",
|
"discord": "Discord / انجمن",
|
||||||
"inhouse": "محل کار خودم (درونسازمانی)",
|
"friend": "دوست یا همکار",
|
||||||
"personal": "پروژههای شخصی / سرگرمی",
|
"github": "GitHub",
|
||||||
"research": "پژوهش دانشگاهی"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "خبرنامه یا وبلاگ",
|
||||||
|
"other": "سایر",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / جستجو",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "آموزشی (دانشجو یا مدرس)",
|
||||||
|
"personal": "استفاده شخصی",
|
||||||
|
"work": "کاری"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "جاینگهدار سوالات نظرسنجی",
|
"placeholder": "جاینگهدار سوالات نظرسنجی",
|
||||||
"questions": {
|
|
||||||
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
|
||||||
"industry": "صنعت اصلی شما چیست؟",
|
|
||||||
"making": "قصد دارید چه چیزی بسازید؟",
|
|
||||||
"purpose": "هدف اصلی شما از استفاده از ComfyUI چیست؟"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||||
"industry": "صنعت اصلی شما چیست؟",
|
"intent": "با ComfyUI میخواهید چه چیزی ایجاد کنید؟",
|
||||||
"making": "قصد دارید چه چیزی بسازید؟",
|
"source": "از کجا با ComfyUI آشنا شدید؟",
|
||||||
"purpose": "هدف اصلی شما از استفاده از ComfyUI چیست؟"
|
"usage": "چگونه قصد دارید از ComfyUI استفاده کنید؟"
|
||||||
},
|
},
|
||||||
"title": "نظرسنجی ابری"
|
"title": "نظرسنجی ابری"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
|
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
|
||||||
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
|
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
|
||||||
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
|
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
|
||||||
"cloudSurvey_steps_industry": "صنعت اصلی شما چیست؟",
|
"cloudSurvey_steps_intent": "با ComfyUI میخواهید چه چیزی ایجاد کنید؟",
|
||||||
"cloudSurvey_steps_making": "برنامه دارید چه چیزی بسازید؟",
|
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
|
||||||
"cloudSurvey_steps_purpose": "عمدتاً قصد دارید از ComfyUI برای چه کاری استفاده کنید؟",
|
"cloudSurvey_steps_usage": "چگونه قصد دارید از ComfyUI استفاده کنید؟",
|
||||||
"cloudWaitlist_contactLink": "اینجا",
|
"cloudWaitlist_contactLink": "اینجا",
|
||||||
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
|
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "Vous préférez exécuter ComfyUI localement ?"
|
"wantToRun": "Vous préférez exécuter ComfyUI localement ?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"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": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "Utilisateur avancé (workflows personnalisés)",
|
"advanced": "Utilisateur avancé (workflows personnalisés)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
|
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
|
||||||
"starting": "Je débute (je suis des tutoriels)"
|
"starting": "Je débute (je suis des tutoriels)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "Architecture",
|
"3d_game": "Assets 3D / assets de jeu",
|
||||||
"education": "Éducation",
|
"api": "Points de terminaison API pour exécuter des workflows",
|
||||||
"film_tv_animation": "Cinéma, télévision et animation",
|
"apps": "Applications simplifiées à partir de workflows",
|
||||||
"fine_art": "Art et illustration",
|
|
||||||
"gaming": "Jeux vidéo",
|
|
||||||
"marketing": "Marketing et publicité",
|
|
||||||
"other": "Autre",
|
|
||||||
"otherPlaceholder": "Veuillez préciser",
|
|
||||||
"product_design": "Design de produits et graphisme",
|
|
||||||
"software": "Logiciels et technologie"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "Assets 3D",
|
|
||||||
"audio": "Audio / musique",
|
"audio": "Audio / musique",
|
||||||
"custom_nodes": "Nœuds et workflows personnalisés",
|
"custom_nodes": "Nœuds personnalisés",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"video": "Vidéo et animation"
|
"not_sure": "Pas sûr",
|
||||||
|
"videos": "Vidéos",
|
||||||
|
"workflows": "Workflows ou pipelines personnalisés"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "Travail pour clients (freelance)",
|
"conference": "Conférence ou événement",
|
||||||
"community": "Contributions communautaires (nœuds, workflows, etc.)",
|
"discord": "Discord / communauté",
|
||||||
"inhouse": "Mon propre lieu de travail (interne)",
|
"friend": "Ami ou collègue",
|
||||||
"personal": "Projets personnels / loisir",
|
"github": "GitHub",
|
||||||
"research": "Recherche académique"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "Newsletter ou blog",
|
||||||
|
"other": "Autre",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / recherche",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "Éducation (étudiant ou enseignant)",
|
||||||
|
"personal": "Usage personnel",
|
||||||
|
"work": "Travail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "Texte indicatif des questions de l'enquête",
|
"placeholder": "Texte indicatif des questions de l'enquête",
|
||||||
"questions": {
|
|
||||||
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
|
||||||
"industry": "Quel est votre secteur d'activité principal ?",
|
|
||||||
"making": "Que prévoyez-vous de créer ?",
|
|
||||||
"purpose": "À quoi utiliserez-vous principalement ComfyUI ?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||||
"industry": "Quel est votre secteur d'activité principal ?",
|
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||||
"making": "Que prévoyez-vous de créer ?",
|
"source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||||
"purpose": "À quoi utiliserez-vous principalement ComfyUI ?"
|
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
|
||||||
},
|
},
|
||||||
"title": "Enquête Cloud"
|
"title": "Enquête Cloud"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "créez en quelques secondes",
|
"cloudStart_title": "créez en quelques secondes",
|
||||||
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
|
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
|
||||||
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||||
"cloudSurvey_steps_industry": "Quel est votre secteur d'activité principal ?",
|
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||||
"cloudSurvey_steps_making": "Que prévoyez-vous de créer ?",
|
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||||
"cloudSurvey_steps_purpose": "À quoi utiliserez-vous principalement ComfyUI ?",
|
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
|
||||||
"cloudWaitlist_contactLink": "ici",
|
"cloudWaitlist_contactLink": "ici",
|
||||||
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
|
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "代わりにローカルでComfyUIを実行したいですか?"
|
"wantToRun": "代わりにローカルでComfyUIを実行したいですか?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "オプションを選択してください。",
|
||||||
|
"describeAnswer": "回答を記述してください。",
|
||||||
|
"selectAtLeastOne": "少なくとも1つ選択してください。"
|
||||||
|
},
|
||||||
|
"intro": "ComfyUIの体験をあなたに合わせてカスタマイズするためにご協力ください。",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "上級ユーザー(カスタムワークフロー)",
|
"advanced": "上級ユーザー(カスタムワークフロー)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "ComfyUI初心者(使用経験なし)",
|
"new": "ComfyUI初心者(使用経験なし)",
|
||||||
"starting": "使い始め(チュートリアルをフォロー中)"
|
"starting": "使い始め(チュートリアルをフォロー中)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "建築",
|
"3d_game": "3Dアセット/ゲームアセット",
|
||||||
"education": "教育",
|
"api": "ワークフロー実行用APIエンドポイント",
|
||||||
"film_tv_animation": "映画、テレビ、アニメーション",
|
"apps": "ワークフローから簡易アプリ作成",
|
||||||
"fine_art": "ファインアート & イラストレーション",
|
"audio": "音声/音楽",
|
||||||
"gaming": "ゲーミング",
|
"custom_nodes": "カスタムノード",
|
||||||
"marketing": "マーケティング & 広告",
|
|
||||||
"other": "その他",
|
|
||||||
"otherPlaceholder": "詳細を入力してください",
|
|
||||||
"product_design": "プロダクト & グラフィックデザイン",
|
|
||||||
"software": "ソフトウェア & テクノロジー"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "3Dアセット",
|
|
||||||
"audio": "オーディオ / 音楽",
|
|
||||||
"custom_nodes": "カスタムノード & ワークフロー",
|
|
||||||
"images": "画像",
|
"images": "画像",
|
||||||
"video": "ビデオ & アニメーション"
|
"not_sure": "まだわからない",
|
||||||
|
"videos": "動画",
|
||||||
|
"workflows": "カスタムワークフローやパイプライン"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "クライアントワーク(フリーランス)",
|
"conference": "カンファレンスやイベント",
|
||||||
"community": "コミュニティ貢献(ノード、ワークフローなど)",
|
"discord": "Discord/コミュニティ",
|
||||||
"inhouse": "自社での利用(社内)",
|
"friend": "友人または同僚",
|
||||||
"personal": "個人プロジェクト/趣味",
|
"github": "GitHub",
|
||||||
"research": "学術研究"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "ニュースレターまたはブログ",
|
||||||
|
"other": "その他",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google/検索",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "教育(学生または教育者)",
|
||||||
|
"personal": "個人利用",
|
||||||
|
"work": "仕事"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "アンケート質問のプレースホルダー",
|
"placeholder": "アンケート質問のプレースホルダー",
|
||||||
"questions": {
|
|
||||||
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
|
||||||
"industry": "あなたの主な業界は何ですか?",
|
|
||||||
"making": "何を作成する予定ですか?",
|
|
||||||
"purpose": "ComfyUIを主に何に使用しますか?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
||||||
"industry": "あなたの主な業界は何ですか?",
|
"intent": "ComfyUIで何を作成したいですか?",
|
||||||
"making": "何を作成する予定ですか?",
|
"source": "ComfyUIをどこで知りましたか?",
|
||||||
"purpose": "ComfyUIを主に何に使用しますか?"
|
"usage": "ComfyUIをどのように利用する予定ですか?"
|
||||||
},
|
},
|
||||||
"title": "クラウドアンケート"
|
"title": "クラウドアンケート"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "数秒で作成を開始",
|
"cloudStart_title": "数秒で作成を開始",
|
||||||
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか?",
|
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか?",
|
||||||
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか?",
|
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか?",
|
||||||
"cloudSurvey_steps_industry": "あなたの主な業界は何ですか?",
|
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか?",
|
||||||
"cloudSurvey_steps_making": "何を作成する予定ですか?",
|
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか?",
|
||||||
"cloudSurvey_steps_purpose": "ComfyUIを主に何に使用しますか?",
|
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
|
||||||
"cloudWaitlist_contactLink": "こちら",
|
"cloudWaitlist_contactLink": "こちら",
|
||||||
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
|
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?"
|
"wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "옵션을 선택해 주세요.",
|
||||||
|
"describeAnswer": "답변을 설명해 주세요.",
|
||||||
|
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
|
||||||
|
},
|
||||||
|
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
|
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
|
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
|
||||||
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
|
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "건축",
|
"3d_game": "3D 에셋 / 게임 에셋",
|
||||||
"education": "교육",
|
"api": "워크플로우 실행용 API 엔드포인트",
|
||||||
"film_tv_animation": "영화, TV 및 애니메이션",
|
"apps": "워크플로우 기반 간소화 앱",
|
||||||
"fine_art": "순수 미술 및 일러스트레이션",
|
|
||||||
"gaming": "게임",
|
|
||||||
"marketing": "마케팅 및 광고",
|
|
||||||
"other": "기타",
|
|
||||||
"otherPlaceholder": "구체적으로 입력",
|
|
||||||
"product_design": "제품 및 그래픽 디자인",
|
|
||||||
"software": "소프트웨어 및 기술"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "3D 에셋",
|
|
||||||
"audio": "오디오 / 음악",
|
"audio": "오디오 / 음악",
|
||||||
"custom_nodes": "사용자 정의 노드 및 워크플로우",
|
"custom_nodes": "커스텀 노드",
|
||||||
"images": "이미지",
|
"images": "이미지",
|
||||||
"video": "비디오 및 애니메이션"
|
"not_sure": "잘 모르겠음",
|
||||||
|
"videos": "비디오",
|
||||||
|
"workflows": "맞춤형 워크플로우 또는 파이프라인"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "클라이언트 작업 (프리랜서)",
|
"conference": "컨퍼런스 또는 이벤트",
|
||||||
"community": "커뮤니티 기여 (노드, 워크플로우 등)",
|
"discord": "Discord / 커뮤니티",
|
||||||
"inhouse": "자사 업무 (내부 사용)",
|
"friend": "친구 또는 동료",
|
||||||
"personal": "개인 프로젝트 / 취미",
|
"github": "GitHub",
|
||||||
"research": "학술 연구"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "뉴스레터 또는 블로그",
|
||||||
|
"other": "기타",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / 검색",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "교육용(학생 또는 교육자)",
|
||||||
|
"personal": "개인용",
|
||||||
|
"work": "업무용"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "설문 질문 자리표시자",
|
"placeholder": "설문 질문 자리표시자",
|
||||||
"questions": {
|
|
||||||
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
|
||||||
"industry": "주로 어떤 업계에서 일하시나요?",
|
|
||||||
"making": "무엇을 만들 계획이신가요?",
|
|
||||||
"purpose": "주로 ComfyUI를 어떤 용도로 사용하실 계획이신가요?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||||
"industry": "주로 어떤 업계에서 일하시나요?",
|
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||||
"making": "무엇을 만들 계획이신가요?",
|
"source": "ComfyUI를 어디서 알게 되셨나요?",
|
||||||
"purpose": "주로 ComfyUI를 어떤 용도로 사용하실 계획이신가요?"
|
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
|
||||||
},
|
},
|
||||||
"title": "클라우드 설문"
|
"title": "클라우드 설문"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "몇 초 만에 제작 시작",
|
"cloudStart_title": "몇 초 만에 제작 시작",
|
||||||
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
|
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
|
||||||
"cloudSurvey_steps_familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
"cloudSurvey_steps_familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||||
"cloudSurvey_steps_industry": "주요 업계는 무엇인가요?",
|
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||||
"cloudSurvey_steps_making": "무엇을 만들 계획인가요?",
|
"cloudSurvey_steps_source": "ComfyUI를 어디서 알게 되셨나요?",
|
||||||
"cloudSurvey_steps_purpose": "주로 ComfyUI를 어떤 용도로 사용하실 계획인가요?",
|
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
|
||||||
"cloudWaitlist_contactLink": "여기",
|
"cloudWaitlist_contactLink": "여기",
|
||||||
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
|
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "Prefere rodar o ComfyUI localmente?"
|
"wantToRun": "Prefere rodar o ComfyUI localmente?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"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": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
|
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "Novo no ComfyUI (nunca usei antes)",
|
"new": "Novo no ComfyUI (nunca usei antes)",
|
||||||
"starting": "Começando agora (seguindo tutoriais)"
|
"starting": "Começando agora (seguindo tutoriais)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "Arquitetura",
|
"3d_game": "Assets 3D / assets para jogos",
|
||||||
"education": "Educação",
|
"api": "Endpoints de API para executar workflows",
|
||||||
"film_tv_animation": "Filmes, TV e animação",
|
"apps": "Apps simplificados a partir de workflows",
|
||||||
"fine_art": "Belas artes e ilustração",
|
|
||||||
"gaming": "Jogos",
|
|
||||||
"marketing": "Marketing e publicidade",
|
|
||||||
"other": "Outro",
|
|
||||||
"otherPlaceholder": "Por favor, especifique",
|
|
||||||
"product_design": "Design de produto e gráfico",
|
|
||||||
"software": "Software e tecnologia"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "Assets 3D",
|
|
||||||
"audio": "Áudio / música",
|
"audio": "Áudio / música",
|
||||||
"custom_nodes": "Nós e fluxos de trabalho personalizados",
|
"custom_nodes": "Nodes personalizados",
|
||||||
"images": "Imagens",
|
"images": "Imagens",
|
||||||
"video": "Vídeo e animação"
|
"not_sure": "Não tenho certeza",
|
||||||
|
"videos": "Vídeos",
|
||||||
|
"workflows": "Workflows ou pipelines personalizados"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "Trabalho para clientes (freelancer)",
|
"conference": "Conferência ou evento",
|
||||||
"community": "Contribuições para a comunidade (nós, fluxos de trabalho, etc.)",
|
"discord": "Discord / comunidade",
|
||||||
"inhouse": "No meu próprio local de trabalho (interno)",
|
"friend": "Amigo ou colega",
|
||||||
"personal": "Projetos pessoais / hobby",
|
"github": "GitHub",
|
||||||
"research": "Pesquisa acadêmica"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "Newsletter ou blog",
|
||||||
|
"other": "Outro",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / busca",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "Educação (estudante ou educador)",
|
||||||
|
"personal": "Uso pessoal",
|
||||||
|
"work": "Trabalho"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "Espaço reservado para perguntas da pesquisa",
|
"placeholder": "Espaço reservado para perguntas da pesquisa",
|
||||||
"questions": {
|
|
||||||
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
|
||||||
"industry": "Qual é o seu setor principal?",
|
|
||||||
"making": "O que você planeja criar?",
|
|
||||||
"purpose": "Para que você pretende usar principalmente o ComfyUI?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||||
"industry": "Qual é o seu setor principal?",
|
"intent": "O que você deseja criar com o ComfyUI?",
|
||||||
"making": "O que você planeja criar?",
|
"source": "Onde você ouviu falar do ComfyUI?",
|
||||||
"purpose": "Para que você pretende usar principalmente o ComfyUI?"
|
"usage": "Como você pretende usar o ComfyUI?"
|
||||||
},
|
},
|
||||||
"title": "Pesquisa da Nuvem"
|
"title": "Pesquisa da Nuvem"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "comece a criar em segundos",
|
"cloudStart_title": "comece a criar em segundos",
|
||||||
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
|
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
|
||||||
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||||
"cloudSurvey_steps_industry": "Qual é o seu setor principal?",
|
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
|
||||||
"cloudSurvey_steps_making": "O que você planeja criar?",
|
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
|
||||||
"cloudSurvey_steps_purpose": "Para que você pretende usar principalmente o ComfyUI?",
|
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
|
||||||
"cloudWaitlist_contactLink": "aqui",
|
"cloudWaitlist_contactLink": "aqui",
|
||||||
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
|
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "Хотите запустить ComfyUI локально?"
|
"wantToRun": "Хотите запустить ComfyUI локально?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "Пожалуйста, выберите вариант.",
|
||||||
|
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
|
||||||
|
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
|
||||||
|
},
|
||||||
|
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
|
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "Новичок в ComfyUI (никогда не использовал)",
|
"new": "Новичок в ComfyUI (никогда не использовал)",
|
||||||
"starting": "Только начинаю (следую руководствам)"
|
"starting": "Только начинаю (следую руководствам)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "Архитектура",
|
"3d_game": "3D-ассеты / игровые ассеты",
|
||||||
"education": "Образование",
|
"api": "API-эндпоинты для запуска рабочих процессов",
|
||||||
"film_tv_animation": "Кино, ТВ и анимация",
|
"apps": "Упрощённые приложения из рабочих процессов",
|
||||||
"fine_art": "Изобразительное искусство и иллюстрация",
|
|
||||||
"gaming": "Игровая индустрия",
|
|
||||||
"marketing": "Маркетинг и реклама",
|
|
||||||
"other": "Другое",
|
|
||||||
"otherPlaceholder": "Пожалуйста, уточните",
|
|
||||||
"product_design": "Продуктовый и графический дизайн",
|
|
||||||
"software": "Программное обеспечение и технологии"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "3D-ресурсы",
|
|
||||||
"audio": "Аудио / музыка",
|
"audio": "Аудио / музыка",
|
||||||
"custom_nodes": "Пользовательские узлы и рабочие процессы",
|
"custom_nodes": "Пользовательские узлы",
|
||||||
"images": "Изображения",
|
"images": "Изображения",
|
||||||
"video": "Видео и анимация"
|
"not_sure": "Не уверен",
|
||||||
|
"videos": "Видео",
|
||||||
|
"workflows": "Пользовательские рабочие процессы или пайплайны"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "Работа с клиентами (фриланс)",
|
"conference": "Конференция или мероприятие",
|
||||||
"community": "Вклад в сообщество (узлы, рабочие процессы и т.д.)",
|
"discord": "Discord / сообщество",
|
||||||
"inhouse": "Моё рабочее место (внутреннее использование)",
|
"friend": "Друг или коллега",
|
||||||
"personal": "Личные проекты / хобби",
|
"github": "GitHub",
|
||||||
"research": "Академические исследования"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "Новостная рассылка или блог",
|
||||||
|
"other": "Другое",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / поиск",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "Образование (студент или преподаватель)",
|
||||||
|
"personal": "Личное использование",
|
||||||
|
"work": "Работа"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "Вопросы для опроса",
|
"placeholder": "Вопросы для опроса",
|
||||||
"questions": {
|
|
||||||
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
|
||||||
"industry": "В какой отрасли вы работаете?",
|
|
||||||
"making": "Что вы планируете создавать?",
|
|
||||||
"purpose": "Для чего вы в основном будетете использовать ComfyUI?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||||
"industry": "В какой отрасли вы работаете?",
|
"intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||||
"making": "Что вы планируете создавать?",
|
"source": "Где вы узнали о ComfyUI?",
|
||||||
"purpose": "Для чего вы в основном будете использовать ComfyUI?"
|
"usage": "Как вы планируете использовать ComfyUI?"
|
||||||
},
|
},
|
||||||
"title": "Облачный опрос"
|
"title": "Облачный опрос"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "начать создавать за секунды",
|
"cloudStart_title": "начать создавать за секунды",
|
||||||
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
|
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
|
||||||
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
|
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||||
"cloudSurvey_steps_industry": "В какой отрасли вы работаете?",
|
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||||
"cloudSurvey_steps_making": "Что вы планируете создавать?",
|
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
|
||||||
"cloudSurvey_steps_purpose": "Для чего вы в основном будете использовать ComfyUI?",
|
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
|
||||||
"cloudWaitlist_contactLink": "здесь",
|
"cloudWaitlist_contactLink": "здесь",
|
||||||
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
|
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?"
|
"wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"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": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
|
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
|
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
|
||||||
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
|
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "Mimarlık",
|
"3d_game": "3D varlıklar / oyun varlıkları",
|
||||||
"education": "Eğitim",
|
"api": "İş akışlarını çalıştırmak için API uç noktaları",
|
||||||
"film_tv_animation": "Film, TV ve animasyon",
|
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
|
||||||
"fine_art": "Güzel sanatlar ve illüstrasyon",
|
|
||||||
"gaming": "Oyun",
|
|
||||||
"marketing": "Pazarlama ve reklamcılık",
|
|
||||||
"other": "Diğer",
|
|
||||||
"otherPlaceholder": "Lütfen belirtin",
|
|
||||||
"product_design": "Ürün ve grafik tasarımı",
|
|
||||||
"software": "Yazılım ve teknoloji"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "3D varlıklar",
|
|
||||||
"audio": "Ses / müzik",
|
"audio": "Ses / müzik",
|
||||||
"custom_nodes": "Özel düğümler ve iş akışları",
|
"custom_nodes": "Özel node'lar",
|
||||||
"images": "Görseller",
|
"images": "Görseller",
|
||||||
"video": "Video ve animasyon"
|
"not_sure": "Emin değilim",
|
||||||
|
"videos": "Videolar",
|
||||||
|
"workflows": "Özel iş akışları veya boru hatları"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "Müşteri işleri (serbest çalışma)",
|
"conference": "Konferans veya etkinlik",
|
||||||
"community": "Topluluk katkıları (düğümler, iş akışları vb.)",
|
"discord": "Discord / topluluk",
|
||||||
"inhouse": "Kendi işyerim (şirket içi)",
|
"friend": "Arkadaş veya iş arkadaşı",
|
||||||
"personal": "Kişisel projeler / hobi",
|
"github": "GitHub",
|
||||||
"research": "Akademik araştırma"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "Bülten veya blog",
|
||||||
|
"other": "Diğer",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / arama",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "Eğitim (öğrenci veya eğitmen)",
|
||||||
|
"personal": "Kişisel kullanım",
|
||||||
|
"work": "İş"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "Anket soruları yer tutucusu",
|
"placeholder": "Anket soruları yer tutucusu",
|
||||||
"questions": {
|
|
||||||
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
|
||||||
"industry": "Birincil sektörünüz nedir?",
|
|
||||||
"making": "Ne yapmayı planlıyorsunuz?",
|
|
||||||
"purpose": "ComfyUI'ı öncelikle ne için kullanacaksınız?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
||||||
"industry": "Birincil sektörünüz nedir?",
|
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||||
"making": "Ne yapmayı planlıyorsunuz?",
|
"source": "ComfyUI'yi nereden duydunuz?",
|
||||||
"purpose": "ComfyUI'ı öncelikle ne için kullanacaksınız?"
|
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
|
||||||
},
|
},
|
||||||
"title": "Bulut Anketi"
|
"title": "Bulut Anketi"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
|
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
|
||||||
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
|
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
|
||||||
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
|
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
|
||||||
"cloudSurvey_steps_industry": "Birincil sektörünüz nedir?",
|
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||||
"cloudSurvey_steps_making": "Ne yapmayı planlıyorsunuz?",
|
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
|
||||||
"cloudSurvey_steps_purpose": "ComfyUI'yı öncelikle ne için kullanacaksınız?",
|
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
|
||||||
"cloudWaitlist_contactLink": "burada",
|
"cloudWaitlist_contactLink": "burada",
|
||||||
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
|
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "想要在本機運行 ComfyUI?"
|
"wantToRun": "想要在本機運行 ComfyUI?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "請選擇一個選項。",
|
||||||
|
"describeAnswer": "請描述您的答案。",
|
||||||
|
"selectAtLeastOne": "請至少選擇一個選項。"
|
||||||
|
},
|
||||||
|
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "進階使用者(自訂工作流程)",
|
"advanced": "進階使用者(自訂工作流程)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "ComfyUI 新手(從未使用過)",
|
"new": "ComfyUI 新手(從未使用過)",
|
||||||
"starting": "剛開始(正在跟隨教學)"
|
"starting": "剛開始(正在跟隨教學)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "建築",
|
"3d_game": "3D 素材/遊戲素材",
|
||||||
"education": "教育",
|
"api": "執行工作流程的 API 端點",
|
||||||
"film_tv_animation": "電影、電視與動畫",
|
"apps": "由工作流程簡化的應用程式",
|
||||||
"fine_art": "美術與插畫",
|
"audio": "音訊/音樂",
|
||||||
"gaming": "遊戲",
|
"custom_nodes": "自訂節點",
|
||||||
"marketing": "行銷與廣告",
|
"images": "影像",
|
||||||
|
"not_sure": "尚未確定",
|
||||||
|
"videos": "影片",
|
||||||
|
"workflows": "自訂工作流程或管線"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"conference": "會議或活動",
|
||||||
|
"discord": "Discord/社群",
|
||||||
|
"friend": "朋友或同事",
|
||||||
|
"github": "GitHub",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "電子報或部落格",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"otherPlaceholder": "請具體說明",
|
"reddit": "Reddit",
|
||||||
"product_design": "產品與平面設計",
|
"search": "Google/搜尋引擎",
|
||||||
"software": "軟體與科技"
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
},
|
},
|
||||||
"making": {
|
"usage": {
|
||||||
"3d": "3D 資產",
|
"education": "教育用途(學生或教育者)",
|
||||||
"audio": "音訊 / 音樂",
|
"personal": "個人用途",
|
||||||
"custom_nodes": "自訂節點與工作流程",
|
"work": "工作用途"
|
||||||
"images": "圖片",
|
|
||||||
"video": "影片與動畫"
|
|
||||||
},
|
|
||||||
"purpose": {
|
|
||||||
"client": "客戶工作(自由接案)",
|
|
||||||
"community": "社群貢獻(節點、工作流程等)",
|
|
||||||
"inhouse": "我的工作場所(公司內部)",
|
|
||||||
"personal": "個人專案/興趣",
|
|
||||||
"research": "學術研究"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "問卷問題佔位符",
|
"placeholder": "問卷問題佔位符",
|
||||||
"questions": {
|
|
||||||
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
|
||||||
"industry": "您的主要行業是什麼?",
|
|
||||||
"making": "您計劃製作什麼?",
|
|
||||||
"purpose": "您主要會使用 ComfyUI 來做什麼?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||||
"industry": "您的主要行業是什麼?",
|
"intent": "您想用 ComfyUI 創作什麼?",
|
||||||
"making": "您計劃製作什麼內容?",
|
"source": "您從哪裡得知 ComfyUI?",
|
||||||
"purpose": "您主要會使用 ComfyUI 來做什麼?"
|
"usage": "您打算如何使用 ComfyUI?"
|
||||||
},
|
},
|
||||||
"title": "雲端問卷"
|
"title": "雲端問卷"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "數秒內開始創作",
|
"cloudStart_title": "數秒內開始創作",
|
||||||
"cloudStart_wantToRun": "想要在本機運行 ComfyUI?",
|
"cloudStart_wantToRun": "想要在本機運行 ComfyUI?",
|
||||||
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||||
"cloudSurvey_steps_industry": "您的主要行業是什麼?",
|
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
|
||||||
"cloudSurvey_steps_making": "您計劃製作什麼?",
|
"cloudSurvey_steps_source": "您從哪裡得知 ComfyUI?",
|
||||||
"cloudSurvey_steps_purpose": "您主要會使用 ComfyUI 做什麼?",
|
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||||
"cloudWaitlist_contactLink": "此處",
|
"cloudWaitlist_contactLink": "此處",
|
||||||
"cloudWaitlist_questionsText": "有問題?聯絡我們",
|
"cloudWaitlist_questionsText": "有問題?聯絡我們",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "月谷馬雷",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
"wantToRun": "想在本机运行 ComfyUI 吗?"
|
"wantToRun": "想在本机运行 ComfyUI 吗?"
|
||||||
},
|
},
|
||||||
"survey": {
|
"survey": {
|
||||||
|
"errors": {
|
||||||
|
"chooseAnOption": "请选择一个选项。",
|
||||||
|
"describeAnswer": "请描述您的答案。",
|
||||||
|
"selectAtLeastOne": "请至少选择一个选项。"
|
||||||
|
},
|
||||||
|
"intro": "帮助我们为您定制 ComfyUI 体验。",
|
||||||
"options": {
|
"options": {
|
||||||
"familiarity": {
|
"familiarity": {
|
||||||
"advanced": "高级用户(自定义工作流)",
|
"advanced": "高级用户(自定义工作流)",
|
||||||
@@ -469,45 +475,43 @@
|
|||||||
"new": "ComfyUI 新手(从未使用过)",
|
"new": "ComfyUI 新手(从未使用过)",
|
||||||
"starting": "刚刚开始(正在学习教程)"
|
"starting": "刚刚开始(正在学习教程)"
|
||||||
},
|
},
|
||||||
"industry": {
|
"intent": {
|
||||||
"architecture": "架构",
|
"3d_game": "3D 资产 / 游戏资产",
|
||||||
"education": "教育",
|
"api": "运行工作流的 API 端点",
|
||||||
"film_tv_animation": "电影、电视与动画",
|
"apps": "基于工作流的简化应用",
|
||||||
"fine_art": "美术与插画",
|
|
||||||
"gaming": "游戏",
|
|
||||||
"marketing": "营销与广告",
|
|
||||||
"other": "其他",
|
|
||||||
"otherPlaceholder": "请指定",
|
|
||||||
"product_design": "产品与平面设计",
|
|
||||||
"software": "软件与技术"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"3d": "3D 资产",
|
|
||||||
"audio": "音频 / 音乐",
|
"audio": "音频 / 音乐",
|
||||||
"custom_nodes": "自定义节点和工作流",
|
"custom_nodes": "自定义节点",
|
||||||
"images": "图片",
|
"images": "图像",
|
||||||
"video": "视频与动画"
|
"not_sure": "不确定",
|
||||||
|
"videos": "视频",
|
||||||
|
"workflows": "自定义工作流或流程"
|
||||||
},
|
},
|
||||||
"purpose": {
|
"source": {
|
||||||
"client": "客户工作(自由职业)",
|
"conference": "会议或活动",
|
||||||
"community": "社区贡献(节点、工作流等)",
|
"discord": "Discord / 社区",
|
||||||
"inhouse": "我自己的工作场所(内部)",
|
"friend": "朋友或同事",
|
||||||
"personal": "个人项目 / 爱好",
|
"github": "GitHub",
|
||||||
"research": "学术研究"
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"newsletter": "新闻通讯或博客",
|
||||||
|
"other": "其他",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"search": "Google / 搜索引擎",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"youtube": "YouTube"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"education": "教育(学生或教师)",
|
||||||
|
"personal": "个人使用",
|
||||||
|
"work": "工作"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"placeholder": "调查问题占位符",
|
"placeholder": "调查问题占位符",
|
||||||
"questions": {
|
|
||||||
"familiarity": "你对 ComfyUI 有多熟悉?",
|
|
||||||
"industry": "您的主要行业是什么?",
|
|
||||||
"making": "你打算做什么?",
|
|
||||||
"purpose": "您主要将使用 ComfyUI 做什么?"
|
|
||||||
},
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"familiarity": "你对 ComfyUI 有多熟悉?",
|
"familiarity": "你对 ComfyUI 有多熟悉?",
|
||||||
"industry": "您的主要行业是什么?",
|
"intent": "您希望用 ComfyUI 创作什么?",
|
||||||
"making": "你打算做什么?",
|
"source": "您是从哪里了解到 ComfyUI 的?",
|
||||||
"purpose": "您将主要使用 ComfyUI 做什么?"
|
"usage": "您打算如何使用 ComfyUI?"
|
||||||
},
|
},
|
||||||
"title": "云调研"
|
"title": "云调研"
|
||||||
}
|
}
|
||||||
@@ -522,9 +526,9 @@
|
|||||||
"cloudStart_title": "几秒钟内开始创作",
|
"cloudStart_title": "几秒钟内开始创作",
|
||||||
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
|
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
|
||||||
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉?",
|
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉?",
|
||||||
"cloudSurvey_steps_industry": "您的主要行业是什么?",
|
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
|
||||||
"cloudSurvey_steps_making": "你打算做什么?",
|
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
|
||||||
"cloudSurvey_steps_purpose": "您将主要使用 ComfyUI 做什么?",
|
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||||
"cloudWaitlist_contactLink": "这里",
|
"cloudWaitlist_contactLink": "这里",
|
||||||
"cloudWaitlist_questionsText": "有问题?联系我们",
|
"cloudWaitlist_questionsText": "有问题?联系我们",
|
||||||
"color": {
|
"color": {
|
||||||
@@ -2259,7 +2263,6 @@
|
|||||||
"Magnific": "Magnific",
|
"Magnific": "Magnific",
|
||||||
"Meshy": "Meshy",
|
"Meshy": "Meshy",
|
||||||
"MiniMax": "MiniMax",
|
"MiniMax": "MiniMax",
|
||||||
"Moonvalley Marey": "Moonvalley Marey",
|
|
||||||
"OpenAI": "OpenAI",
|
"OpenAI": "OpenAI",
|
||||||
"PixVerse": "PixVerse",
|
"PixVerse": "PixVerse",
|
||||||
"Quiver": "Quiver",
|
"Quiver": "Quiver",
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { render, screen } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
|
|
||||||
|
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||||
|
|
||||||
|
const mockHandleSubscribeClick = vi.fn()
|
||||||
|
const mockHandleBackToPricing = vi.fn()
|
||||||
|
const mockHandleAddCreditCard = vi.fn()
|
||||||
|
const mockHandleConfirmTransition = vi.fn()
|
||||||
|
const mockHandleResubscribe = vi.fn()
|
||||||
|
const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing')
|
||||||
|
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||||
|
|
||||||
|
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||||
|
useSubscriptionCheckout: () => ({
|
||||||
|
checkoutStep: mockCheckoutStep,
|
||||||
|
isLoadingPreview: ref(false),
|
||||||
|
loadingTier: ref(null),
|
||||||
|
isSubscribing: ref(false),
|
||||||
|
isResubscribing: ref(false),
|
||||||
|
previewData: mockPreviewData,
|
||||||
|
selectedTierKey: ref('standard'),
|
||||||
|
selectedBillingCycle: ref('yearly'),
|
||||||
|
isPolling: ref(false),
|
||||||
|
handleSubscribeClick: mockHandleSubscribeClick,
|
||||||
|
handleBackToPricing: mockHandleBackToPricing,
|
||||||
|
handleAddCreditCard: mockHandleAddCreditCard,
|
||||||
|
handleConfirmTransition: mockHandleConfirmTransition,
|
||||||
|
handleResubscribe: mockHandleResubscribe
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
g: { back: 'Back', close: 'Close' },
|
||||||
|
subscription: {
|
||||||
|
plansForWorkspace: 'Plans for {workspace}',
|
||||||
|
teamWorkspace: 'Team'
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
topUp: {
|
||||||
|
insufficientTitle: 'Insufficient Credits',
|
||||||
|
insufficientMessage: 'You have run out of credits.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const PricingTableStub = {
|
||||||
|
name: 'PricingTableWorkspace',
|
||||||
|
template: `<div data-testid="pricing-table">
|
||||||
|
<button data-testid="subscribe-btn" @click="$emit('subscribe', { tierKey: 'standard', billingCycle: 'yearly' })">Subscribe</button>
|
||||||
|
<button data-testid="resubscribe-btn" @click="$emit('resubscribe')">Resubscribe</button>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddPaymentPreviewStub = {
|
||||||
|
name: 'SubscriptionAddPaymentPreviewWorkspace',
|
||||||
|
template: `<div data-testid="add-payment-preview">
|
||||||
|
<button data-testid="add-card-btn" @click="$emit('addCreditCard')">Add Card</button>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransitionPreviewStub = {
|
||||||
|
name: 'SubscriptionTransitionPreviewWorkspace',
|
||||||
|
template: `<div data-testid="transition-preview">
|
||||||
|
<button data-testid="confirm-btn" @click="$emit('confirm')">Confirm</button>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(
|
||||||
|
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
|
||||||
|
) {
|
||||||
|
return render(SubscriptionRequiredDialogContentWorkspace, {
|
||||||
|
props: {
|
||||||
|
onClose: props.onClose ?? vi.fn(),
|
||||||
|
...(props.reason ? { reason: props.reason } : {})
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||||
|
i18n
|
||||||
|
],
|
||||||
|
stubs: {
|
||||||
|
PricingTableWorkspace: PricingTableStub,
|
||||||
|
SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub,
|
||||||
|
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockCheckoutStep.value = 'pricing'
|
||||||
|
mockPreviewData.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows pricing table on pricing step', () => {
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.getByTestId('pricing-table')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows close button and hides back button on pricing step', () => {
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.getByLabelText('Close')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('Back')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when close button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
renderComponent({ onClose })
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText('Close'))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows back button on preview step', () => {
|
||||||
|
mockCheckoutStep.value = 'preview'
|
||||||
|
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.getByLabelText('Back')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows insufficient credits message when reason is out_of_credits', () => {
|
||||||
|
renderComponent({ reason: 'out_of_credits' })
|
||||||
|
expect(screen.getByText('Insufficient Credits')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('You have run out of credits.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show insufficient credits message without reason', () => {
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.queryByText('Insufficient Credits')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows new subscription preview when transition_type is new_subscription', () => {
|
||||||
|
mockCheckoutStep.value = 'preview'
|
||||||
|
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.getByTestId('add-payment-preview')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows transition preview when transition_type is upgrade', () => {
|
||||||
|
mockCheckoutStep.value = 'preview'
|
||||||
|
mockPreviewData.value = { transition_type: 'upgrade' }
|
||||||
|
renderComponent()
|
||||||
|
expect(screen.getByTestId('transition-preview')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires subscribe event to handleSubscribeClick', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderComponent()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('subscribe-btn'))
|
||||||
|
|
||||||
|
expect(mockHandleSubscribeClick).toHaveBeenCalledWith({
|
||||||
|
tierKey: 'standard',
|
||||||
|
billingCycle: 'yearly'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires resubscribe event to handleResubscribe', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderComponent()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('resubscribe-btn'))
|
||||||
|
|
||||||
|
expect(mockHandleResubscribe).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires back button to handleBackToPricing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockCheckoutStep.value = 'preview'
|
||||||
|
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||||
|
renderComponent()
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText('Back'))
|
||||||
|
|
||||||
|
expect(mockHandleBackToPricing).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
variant="muted-textonly"
|
variant="muted-textonly"
|
||||||
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||||
:aria-label="$t('g.close')"
|
:aria-label="$t('g.close')"
|
||||||
@click="handleClose"
|
@click="onClose"
|
||||||
>
|
>
|
||||||
<i class="pi pi-times text-xl" />
|
<i class="pi pi-times text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -94,28 +94,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useToast } from 'primevue/usetoast'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
|
||||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
|
||||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
|
||||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
|
||||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
|
||||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
|
||||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
|
||||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
|
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||||
|
|
||||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||||
|
|
||||||
type CheckoutStep = 'pricing' | 'preview'
|
|
||||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
|
||||||
|
|
||||||
const { onClose, reason } = defineProps<{
|
const { onClose, reason } = defineProps<{
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
reason?: SubscriptionDialogReason
|
reason?: SubscriptionDialogReason
|
||||||
@@ -125,227 +111,22 @@ const emit = defineEmits<{
|
|||||||
close: [subscribed: boolean]
|
close: [subscribed: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const {
|
||||||
const toast = useToast()
|
checkoutStep,
|
||||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
isLoadingPreview,
|
||||||
useBillingContext()
|
loadingTier,
|
||||||
const telemetry = useTelemetry()
|
isSubscribing,
|
||||||
const billingOperationStore = useBillingOperationStore()
|
isResubscribing,
|
||||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
previewData,
|
||||||
|
selectedTierKey,
|
||||||
const checkoutStep = ref<CheckoutStep>('pricing')
|
selectedBillingCycle,
|
||||||
const isLoadingPreview = ref(false)
|
isPolling,
|
||||||
const loadingTier = ref<CheckoutTierKey | null>(null)
|
handleSubscribeClick,
|
||||||
const isSubscribing = ref(false)
|
handleBackToPricing,
|
||||||
const isResubscribing = ref(false)
|
handleAddCreditCard,
|
||||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
handleConfirmTransition,
|
||||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
handleResubscribe
|
||||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
} = useSubscriptionCheckout(emit)
|
||||||
|
|
||||||
function getApiPlanSlug(
|
|
||||||
tierKey: CheckoutTierKey,
|
|
||||||
billingCycle: BillingCycle
|
|
||||||
): string | null {
|
|
||||||
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
|
||||||
const apiTier = tierKey.toUpperCase()
|
|
||||||
const plan = plans.value.find(
|
|
||||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
|
||||||
)
|
|
||||||
return plan?.slug ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubscribeClick(payload: {
|
|
||||||
tierKey: CheckoutTierKey
|
|
||||||
billingCycle: BillingCycle
|
|
||||||
}) {
|
|
||||||
const { tierKey, billingCycle } = payload
|
|
||||||
|
|
||||||
isLoadingPreview.value = true
|
|
||||||
loadingTier.value = tierKey
|
|
||||||
selectedTierKey.value = tierKey
|
|
||||||
selectedBillingCycle.value = billingCycle
|
|
||||||
|
|
||||||
try {
|
|
||||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
|
||||||
if (!planSlug) {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Unable to subscribe',
|
|
||||||
detail: 'This plan is not available'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const response = await previewSubscribe(planSlug)
|
|
||||||
|
|
||||||
if (!response || !response.allowed) {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Unable to subscribe',
|
|
||||||
detail: response?.reason || 'This plan is not available'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
previewData.value = response
|
|
||||||
checkoutStep.value = 'preview'
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to load subscription preview'
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: message
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isLoadingPreview.value = false
|
|
||||||
loadingTier.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackToPricing() {
|
|
||||||
checkoutStep.value = 'pricing'
|
|
||||||
previewData.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddCreditCard() {
|
|
||||||
if (!selectedTierKey.value) return
|
|
||||||
|
|
||||||
isSubscribing.value = true
|
|
||||||
try {
|
|
||||||
const planSlug = getApiPlanSlug(
|
|
||||||
selectedTierKey.value,
|
|
||||||
selectedBillingCycle.value
|
|
||||||
)
|
|
||||||
if (!planSlug) return
|
|
||||||
const response = await subscribe(
|
|
||||||
planSlug,
|
|
||||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
|
||||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response) return
|
|
||||||
|
|
||||||
if (response.status === 'subscribed') {
|
|
||||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: t('subscription.required.pollingSuccess'),
|
|
||||||
life: 5000
|
|
||||||
})
|
|
||||||
await Promise.all([fetchStatus(), fetchBalance()])
|
|
||||||
emit('close', true)
|
|
||||||
} else if (
|
|
||||||
response.status === 'needs_payment_method' &&
|
|
||||||
response.payment_method_url
|
|
||||||
) {
|
|
||||||
window.open(response.payment_method_url, '_blank')
|
|
||||||
billingOperationStore.startOperation(
|
|
||||||
response.billing_op_id,
|
|
||||||
'subscription'
|
|
||||||
)
|
|
||||||
} else if (response.status === 'pending_payment') {
|
|
||||||
billingOperationStore.startOperation(
|
|
||||||
response.billing_op_id,
|
|
||||||
'subscription'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : 'Failed to subscribe'
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: message
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isSubscribing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmTransition() {
|
|
||||||
if (!selectedTierKey.value) return
|
|
||||||
|
|
||||||
isSubscribing.value = true
|
|
||||||
try {
|
|
||||||
const planSlug = getApiPlanSlug(
|
|
||||||
selectedTierKey.value,
|
|
||||||
selectedBillingCycle.value
|
|
||||||
)
|
|
||||||
if (!planSlug) return
|
|
||||||
const response = await subscribe(
|
|
||||||
planSlug,
|
|
||||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
|
||||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response) return
|
|
||||||
|
|
||||||
if (response.status === 'subscribed') {
|
|
||||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: t('subscription.required.pollingSuccess'),
|
|
||||||
life: 5000
|
|
||||||
})
|
|
||||||
await Promise.all([fetchStatus(), fetchBalance()])
|
|
||||||
emit('close', true)
|
|
||||||
} else if (
|
|
||||||
response.status === 'needs_payment_method' &&
|
|
||||||
response.payment_method_url
|
|
||||||
) {
|
|
||||||
window.open(response.payment_method_url, '_blank')
|
|
||||||
billingOperationStore.startOperation(
|
|
||||||
response.billing_op_id,
|
|
||||||
'subscription'
|
|
||||||
)
|
|
||||||
} else if (response.status === 'pending_payment') {
|
|
||||||
billingOperationStore.startOperation(
|
|
||||||
response.billing_op_id,
|
|
||||||
'subscription'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : 'Failed to update subscription'
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: message
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isSubscribing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResubscribe() {
|
|
||||||
isResubscribing.value = true
|
|
||||||
try {
|
|
||||||
await workspaceApi.resubscribe()
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: t('subscription.resubscribeSuccess'),
|
|
||||||
life: 5000
|
|
||||||
})
|
|
||||||
await Promise.all([fetchStatus(), fetchBalance()])
|
|
||||||
emit('close', true)
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: message
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isResubscribing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||||
|
|
||||||
|
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||||
|
|
||||||
|
function makeStandardYearly(): Plan {
|
||||||
|
return {
|
||||||
|
slug: 'standard-yearly',
|
||||||
|
tier: 'STANDARD',
|
||||||
|
duration: 'ANNUAL',
|
||||||
|
price_cents: 1600,
|
||||||
|
credits_cents: 4200,
|
||||||
|
max_seats: 1,
|
||||||
|
availability: { available: true },
|
||||||
|
seat_summary: {
|
||||||
|
seat_count: 1,
|
||||||
|
total_cost_cents: 1600,
|
||||||
|
total_credits_cents: 4200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatorMonthly(): Plan {
|
||||||
|
return {
|
||||||
|
slug: 'creator-monthly',
|
||||||
|
tier: 'CREATOR',
|
||||||
|
duration: 'MONTHLY',
|
||||||
|
price_cents: 3500,
|
||||||
|
credits_cents: 7400,
|
||||||
|
max_seats: 5,
|
||||||
|
availability: { available: true },
|
||||||
|
seat_summary: {
|
||||||
|
seat_count: 1,
|
||||||
|
total_cost_cents: 3500,
|
||||||
|
total_credits_cents: 7400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allPlans(): Plan[] {
|
||||||
|
return [makeStandardYearly(), makeCreatorMonthly()]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findPlanSlug', () => {
|
||||||
|
it('finds an annual plan by tier key and yearly billing cycle', () => {
|
||||||
|
expect(findPlanSlug(allPlans(), 'standard', 'yearly')).toBe(
|
||||||
|
'standard-yearly'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a monthly plan by tier key and monthly billing cycle', () => {
|
||||||
|
expect(findPlanSlug(allPlans(), 'creator', 'monthly')).toBe(
|
||||||
|
'creator-monthly'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no plan matches', () => {
|
||||||
|
expect(findPlanSlug(allPlans(), 'standard', 'monthly')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for empty plans', () => {
|
||||||
|
expect(findPlanSlug([], 'standard', 'yearly')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockSubscribe,
|
||||||
|
mockPreviewSubscribe,
|
||||||
|
mockFetchStatus,
|
||||||
|
mockFetchBalance,
|
||||||
|
mockPlans,
|
||||||
|
mockResubscribe,
|
||||||
|
mockToastAdd
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockSubscribe: vi.fn(),
|
||||||
|
mockPreviewSubscribe: vi.fn(),
|
||||||
|
mockFetchStatus: vi.fn(),
|
||||||
|
mockFetchBalance: vi.fn(),
|
||||||
|
mockPlans: { value: [] as Plan[] },
|
||||||
|
mockResubscribe: vi.fn(),
|
||||||
|
mockToastAdd: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||||
|
useBillingContext: () => ({
|
||||||
|
subscribe: mockSubscribe,
|
||||||
|
previewSubscribe: mockPreviewSubscribe,
|
||||||
|
plans: computed(() => mockPlans.value),
|
||||||
|
fetchStatus: mockFetchStatus,
|
||||||
|
fetchBalance: mockFetchBalance
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||||
|
workspaceApi: { resubscribe: mockResubscribe }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/config/comfyApi', () => ({
|
||||||
|
getComfyPlatformBaseUrl: () => 'https://platform.comfy.org'
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('primevue/usetoast', () => ({
|
||||||
|
useToast: () => ({ add: mockToastAdd })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/telemetry', () => ({
|
||||||
|
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal()
|
||||||
|
return {
|
||||||
|
...(actual as Record<string, unknown>),
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useSubscriptionCheckout', () => {
|
||||||
|
let emit: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const { useSubscriptionCheckout } =
|
||||||
|
await import('./useSubscriptionCheckout')
|
||||||
|
return useSubscriptionCheckout(emit as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPlans.value = allPlans()
|
||||||
|
emit = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleSubscribeClick', () => {
|
||||||
|
it('transitions to preview on successful preview', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
const preview = {
|
||||||
|
allowed: true,
|
||||||
|
transition_type: 'new_subscription' as const,
|
||||||
|
effective_at: '2025-01-01',
|
||||||
|
is_immediate: true,
|
||||||
|
cost_today_cents: 1600,
|
||||||
|
cost_next_period_cents: 1600,
|
||||||
|
credits_today_cents: 4200,
|
||||||
|
credits_next_period_cents: 4200,
|
||||||
|
new_plan: makeStandardYearly().seat_summary
|
||||||
|
}
|
||||||
|
mockPreviewSubscribe.mockResolvedValueOnce(preview)
|
||||||
|
|
||||||
|
await checkout.handleSubscribeClick({
|
||||||
|
tierKey: 'standard',
|
||||||
|
billingCycle: 'yearly'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(checkout.checkoutStep.value).toBe('preview')
|
||||||
|
expect(checkout.previewData.value).toStrictEqual(preview)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when preview is disallowed', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||||
|
allowed: false,
|
||||||
|
reason: 'Not allowed'
|
||||||
|
})
|
||||||
|
|
||||||
|
await checkout.handleSubscribeClick({
|
||||||
|
tierKey: 'standard',
|
||||||
|
billingCycle: 'yearly'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Not allowed'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when plan slug is not found', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockPlans.value = []
|
||||||
|
|
||||||
|
await checkout.handleSubscribeClick({
|
||||||
|
tierKey: 'standard',
|
||||||
|
billingCycle: 'yearly'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'This plan is not available'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast on network failure', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockPreviewSubscribe.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
await checkout.handleSubscribeClick({
|
||||||
|
tierKey: 'standard',
|
||||||
|
billingCycle: 'yearly'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Network error'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves monthly billing cycle to correct plan slug', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||||
|
allowed: true,
|
||||||
|
transition_type: 'new_subscription'
|
||||||
|
})
|
||||||
|
|
||||||
|
await checkout.handleSubscribeClick({
|
||||||
|
tierKey: 'creator',
|
||||||
|
billingCycle: 'monthly'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPreviewSubscribe).toHaveBeenCalledWith('creator-monthly')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleBackToPricing', () => {
|
||||||
|
it('resets to pricing step and clears preview data', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.checkoutStep.value = 'preview'
|
||||||
|
checkout.previewData.value = {} as never
|
||||||
|
|
||||||
|
checkout.handleBackToPricing()
|
||||||
|
|
||||||
|
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||||
|
expect(checkout.previewData.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleAddCreditCard', () => {
|
||||||
|
it('emits close on subscribed status', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.selectedTierKey.value = 'standard'
|
||||||
|
checkout.selectedBillingCycle.value = 'yearly'
|
||||||
|
mockSubscribe.mockResolvedValueOnce({
|
||||||
|
status: 'subscribed',
|
||||||
|
billing_op_id: 'op-1'
|
||||||
|
})
|
||||||
|
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||||
|
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||||
|
|
||||||
|
await checkout.handleAddCreditCard()
|
||||||
|
|
||||||
|
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||||
|
'standard-yearly',
|
||||||
|
'https://platform.comfy.org/payment/success',
|
||||||
|
'https://platform.comfy.org/payment/failed'
|
||||||
|
)
|
||||||
|
expect(emit).toHaveBeenCalledWith('close', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens payment URL when needs_payment_method', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.selectedTierKey.value = 'standard'
|
||||||
|
checkout.selectedBillingCycle.value = 'yearly'
|
||||||
|
mockSubscribe.mockResolvedValueOnce({
|
||||||
|
status: 'needs_payment_method',
|
||||||
|
billing_op_id: 'op-2',
|
||||||
|
payment_method_url: 'https://stripe.com/pay'
|
||||||
|
})
|
||||||
|
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
await checkout.handleAddCreditCard()
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('https://stripe.com/pay', '_blank')
|
||||||
|
openSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast on subscribe failure', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.selectedTierKey.value = 'standard'
|
||||||
|
checkout.selectedBillingCycle.value = 'yearly'
|
||||||
|
mockSubscribe.mockRejectedValueOnce(new Error('Payment failed'))
|
||||||
|
|
||||||
|
await checkout.handleAddCreditCard()
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Payment failed'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleConfirmTransition', () => {
|
||||||
|
it('emits close on subscribed status', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.selectedTierKey.value = 'standard'
|
||||||
|
checkout.selectedBillingCycle.value = 'yearly'
|
||||||
|
mockSubscribe.mockResolvedValueOnce({
|
||||||
|
status: 'subscribed',
|
||||||
|
billing_op_id: 'op-3'
|
||||||
|
})
|
||||||
|
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||||
|
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||||
|
|
||||||
|
await checkout.handleConfirmTransition()
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledWith('close', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast on failure', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
checkout.selectedTierKey.value = 'standard'
|
||||||
|
checkout.selectedBillingCycle.value = 'yearly'
|
||||||
|
mockSubscribe.mockRejectedValueOnce(new Error('Transition error'))
|
||||||
|
|
||||||
|
await checkout.handleConfirmTransition()
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Transition error'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleResubscribe', () => {
|
||||||
|
it('emits close on success', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockResubscribe.mockResolvedValueOnce({
|
||||||
|
billing_op_id: 'op-4',
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||||
|
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||||
|
|
||||||
|
await checkout.handleResubscribe()
|
||||||
|
|
||||||
|
expect(mockResubscribe).toHaveBeenCalled()
|
||||||
|
expect(emit).toHaveBeenCalledWith('close', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast on failure', async () => {
|
||||||
|
const checkout = await setup()
|
||||||
|
mockResubscribe.mockRejectedValueOnce(new Error('Resubscribe failed'))
|
||||||
|
|
||||||
|
await checkout.handleResubscribe()
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Resubscribe failed'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||||
|
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||||
|
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
|
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
|
import type {
|
||||||
|
Plan,
|
||||||
|
PreviewSubscribeResponse
|
||||||
|
} from '@/platform/workspace/api/workspaceApi'
|
||||||
|
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||||
|
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||||
|
|
||||||
|
type CheckoutStep = 'pricing' | 'preview'
|
||||||
|
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||||
|
|
||||||
|
export function findPlanSlug(
|
||||||
|
plans: Plan[],
|
||||||
|
tierKey: CheckoutTierKey,
|
||||||
|
billingCycle: BillingCycle
|
||||||
|
): string | null {
|
||||||
|
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||||
|
const apiTier = tierKey.toUpperCase()
|
||||||
|
const plan = plans.find(
|
||||||
|
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||||
|
)
|
||||||
|
return plan?.slug ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscriptionCheckout(emit: {
|
||||||
|
(e: 'close', subscribed: boolean): void
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||||
|
useBillingContext()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
const billingOperationStore = useBillingOperationStore()
|
||||||
|
|
||||||
|
const checkoutStep = ref<CheckoutStep>('pricing')
|
||||||
|
const isLoadingPreview = ref(false)
|
||||||
|
const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||||
|
const isSubscribing = ref(false)
|
||||||
|
const isResubscribing = ref(false)
|
||||||
|
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||||
|
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||||
|
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||||
|
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||||
|
|
||||||
|
function getApiPlanSlug(
|
||||||
|
tierKey: CheckoutTierKey,
|
||||||
|
billingCycle: BillingCycle
|
||||||
|
): string | null {
|
||||||
|
return findPlanSlug(plans.value, tierKey, billingCycle)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscribeClick(payload: {
|
||||||
|
tierKey: CheckoutTierKey
|
||||||
|
billingCycle: BillingCycle
|
||||||
|
}) {
|
||||||
|
const { tierKey, billingCycle } = payload
|
||||||
|
|
||||||
|
isLoadingPreview.value = true
|
||||||
|
loadingTier.value = tierKey
|
||||||
|
selectedTierKey.value = tierKey
|
||||||
|
selectedBillingCycle.value = billingCycle
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||||
|
if (!planSlug) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Unable to subscribe',
|
||||||
|
detail: 'This plan is not available'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await previewSubscribe(planSlug)
|
||||||
|
|
||||||
|
if (!response || !response.allowed) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Unable to subscribe',
|
||||||
|
detail: response?.reason || 'This plan is not available'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewData.value = response
|
||||||
|
checkoutStep.value = 'preview'
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to load subscription preview'
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoadingPreview.value = false
|
||||||
|
loadingTier.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToPricing() {
|
||||||
|
checkoutStep.value = 'pricing'
|
||||||
|
previewData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscription() {
|
||||||
|
if (!selectedTierKey.value) return
|
||||||
|
|
||||||
|
isSubscribing.value = true
|
||||||
|
try {
|
||||||
|
const planSlug = getApiPlanSlug(
|
||||||
|
selectedTierKey.value,
|
||||||
|
selectedBillingCycle.value
|
||||||
|
)
|
||||||
|
if (!planSlug) return
|
||||||
|
const response = await subscribe(
|
||||||
|
planSlug,
|
||||||
|
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||||
|
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
if (response.status === 'subscribed') {
|
||||||
|
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('subscription.required.pollingSuccess'),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
await Promise.all([fetchStatus(), fetchBalance()])
|
||||||
|
emit('close', true)
|
||||||
|
} else if (
|
||||||
|
response.status === 'needs_payment_method' &&
|
||||||
|
response.payment_method_url
|
||||||
|
) {
|
||||||
|
window.open(response.payment_method_url, '_blank')
|
||||||
|
billingOperationStore.startOperation(
|
||||||
|
response.billing_op_id,
|
||||||
|
'subscription'
|
||||||
|
)
|
||||||
|
} else if (response.status === 'pending_payment') {
|
||||||
|
billingOperationStore.startOperation(
|
||||||
|
response.billing_op_id,
|
||||||
|
'subscription'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to subscribe'
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSubscribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResubscribe() {
|
||||||
|
isResubscribing.value = true
|
||||||
|
try {
|
||||||
|
await workspaceApi.resubscribe()
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('subscription.resubscribeSuccess'),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
await Promise.all([fetchStatus(), fetchBalance()])
|
||||||
|
emit('close', true)
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isResubscribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkoutStep,
|
||||||
|
isLoadingPreview,
|
||||||
|
loadingTier,
|
||||||
|
isSubscribing,
|
||||||
|
isResubscribing,
|
||||||
|
previewData,
|
||||||
|
selectedTierKey,
|
||||||
|
selectedBillingCycle,
|
||||||
|
isPolling,
|
||||||
|
handleSubscribeClick,
|
||||||
|
handleBackToPricing,
|
||||||
|
handleAddCreditCard: handleSubscription,
|
||||||
|
handleConfirmTransition: handleSubscription,
|
||||||
|
handleResubscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
|
|||||||
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
|
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
|
||||||
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
|
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
|
||||||
],
|
],
|
||||||
properties: {},
|
properties: {
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'v1-5-pruned-emaonly-fp16.safetensors',
|
||||||
|
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors',
|
||||||
|
directory: 'checkpoints'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
|
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
48
src/scripts/metadata/__fixtures__/helpers.ts
Normal file
48
src/scripts/metadata/__fixtures__/helpers.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
export const EXPECTED_WORKFLOW = {
|
||||||
|
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPECTED_PROMPT = {
|
||||||
|
'1': { class_type: 'KSampler', inputs: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadMethod = 'readAsText' | 'readAsArrayBuffer'
|
||||||
|
|
||||||
|
export function mockFileReaderError(method: ReadMethod): void {
|
||||||
|
vi.spyOn(FileReader.prototype, method).mockImplementation(
|
||||||
|
function (this: FileReader) {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
this.onerror?.(new ProgressEvent('error') as ProgressEvent<FileReader>)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockFileReaderAbort(method: ReadMethod): void {
|
||||||
|
vi.spyOn(FileReader.prototype, method).mockImplementation(
|
||||||
|
function (this: FileReader) {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
this.onabort?.(new ProgressEvent('abort') as ProgressEvent<FileReader>)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockFileReaderResult(
|
||||||
|
method: ReadMethod,
|
||||||
|
result: string | ArrayBuffer | null
|
||||||
|
): void {
|
||||||
|
vi.spyOn(FileReader.prototype, method).mockImplementation(
|
||||||
|
function (this: FileReader) {
|
||||||
|
Object.defineProperty(this, 'result', {
|
||||||
|
value: result,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
queueMicrotask(() =>
|
||||||
|
this.onload?.(new ProgressEvent('load') as ProgressEvent<FileReader>)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src/scripts/metadata/__fixtures__/with_metadata.avif
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 552 B |
BIN
src/scripts/metadata/__fixtures__/with_metadata.flac
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.flac
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp3
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp3
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp4
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp4
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_metadata.opus
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.opus
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_metadata.webm
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.webm
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_metadata.webp
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 B |
BIN
src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 B |
@@ -1,7 +1,76 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
import { getFromAvifFile } from './avif'
|
import { getFromAvifFile } from './avif'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('AVIF metadata', () => {
|
||||||
|
it('extracts workflow and prompt from EXIF data in ISOBMFF boxes', async () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const file = new File([bytes], 'test.avif', { type: 'image/avif' })
|
||||||
|
|
||||||
|
const result = await getFromAvifFile(file)
|
||||||
|
|
||||||
|
expect(JSON.parse(result.workflow)).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for non-AVIF data', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const file = new File([new Uint8Array(16)], 'fake.avif')
|
||||||
|
|
||||||
|
const result = await getFromAvifFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Not a valid AVIF file')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when AVIF has valid ftyp but corrupt internal boxes', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const buf = new Uint8Array(40)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
dv.setUint32(0, 16)
|
||||||
|
buf.set(new TextEncoder().encode('ftypavif'), 4)
|
||||||
|
dv.setUint32(16, 24)
|
||||||
|
buf.set(new TextEncoder().encode('meta'), 20)
|
||||||
|
|
||||||
|
const file = new File([buf], 'corrupt.avif', { type: 'image/avif' })
|
||||||
|
const result = await getFromAvifFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Error parsing AVIF metadata'),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.avif')
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires error', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
expect(await getFromAvifFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
expect(await getFromAvifFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const setU32BE = (dv: DataView, off: number, val: number) =>
|
const setU32BE = (dv: DataView, off: number, val: number) =>
|
||||||
dv.setUint32(off, val, false)
|
dv.setUint32(off, val, false)
|
||||||
const setU16BE = (dv: DataView, off: number, val: number) =>
|
const setU16BE = (dv: DataView, off: number, val: number) =>
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export function getFromAvifFile(file: File): Promise<Record<string, string>> {
|
|||||||
console.error('FileReader: Error reading AVIF file:', err)
|
console.error('FileReader: Error reading AVIF file:', err)
|
||||||
resolve({})
|
resolve({})
|
||||||
}
|
}
|
||||||
|
reader.onabort = () => resolve({})
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/scripts/metadata/ebml.test.ts
Normal file
49
src/scripts/metadata/ebml.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getFromWebmFile } from './ebml'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm')
|
||||||
|
|
||||||
|
describe('WebM/EBML metadata', () => {
|
||||||
|
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const file = new File([bytes], 'test.webm', { type: 'video/webm' })
|
||||||
|
|
||||||
|
const result = await getFromWebmFile(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for non-WebM data', async () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'fake.webm')
|
||||||
|
|
||||||
|
const result = await getFromWebmFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.webm')
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
expect(await getFromWebmFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
expect(await getFromWebmFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -353,6 +353,7 @@ export function getFromWebmFile(file: File): Promise<ComfyMetadata> {
|
|||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => handleFileLoad(event, resolve)
|
reader.onload = (event) => handleFileLoad(event, resolve)
|
||||||
reader.onerror = () => resolve({})
|
reader.onerror = () => resolve({})
|
||||||
|
reader.onabort = () => resolve({})
|
||||||
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/scripts/metadata/flac.test.ts
Normal file
56
src/scripts/metadata/flac.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getFromFlacBuffer, getFromFlacFile } from './flac'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.flac')
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('FLAC metadata', () => {
|
||||||
|
it('extracts workflow and prompt from Vorbis comments', () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const buffer = bytes.buffer.slice(
|
||||||
|
bytes.byteOffset,
|
||||||
|
bytes.byteOffset + bytes.byteLength
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = getFromFlacBuffer(buffer)
|
||||||
|
|
||||||
|
expect(result.workflow).toBe(JSON.stringify(EXPECTED_WORKFLOW))
|
||||||
|
expect(result.prompt).toBe(JSON.stringify(EXPECTED_PROMPT))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for non-FLAC data', () => {
|
||||||
|
const buf = new ArrayBuffer(16)
|
||||||
|
const result = getFromFlacBuffer(buf)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.flac')
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getFromFlacFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getFromFlacFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,6 +42,8 @@ export function getFromFlacFile(file: File): Promise<Record<string, string>> {
|
|||||||
const arrayBuffer = event.target.result as ArrayBuffer
|
const arrayBuffer = event.target.result as ArrayBuffer
|
||||||
r(getFromFlacBuffer(arrayBuffer))
|
r(getFromFlacBuffer(arrayBuffer))
|
||||||
}
|
}
|
||||||
|
reader.onerror = () => r({})
|
||||||
|
reader.onabort = () => r({})
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
|
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
import { getGltfBinaryMetadata } from './gltf'
|
import { getGltfBinaryMetadata } from './gltf'
|
||||||
|
|
||||||
describe('GLTF binary metadata parser', () => {
|
describe('GLTF binary metadata parser', () => {
|
||||||
@@ -160,4 +164,20 @@ describe('GLTF binary metadata parser', () => {
|
|||||||
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
|
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
|
||||||
expect(metadata).toEqual({})
|
expect(metadata).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.glb')
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
expect(await getGltfBinaryMetadata(file)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
expect(await getGltfBinaryMetadata(file)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export function getGltfBinaryMetadata(file: File): Promise<ComfyMetadata> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.onerror = () => resolve({})
|
reader.onerror = () => resolve({})
|
||||||
|
reader.onabort = () => resolve({})
|
||||||
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
|
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/scripts/metadata/isobmff.test.ts
Normal file
52
src/scripts/metadata/isobmff.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getFromIsobmffFile } from './isobmff'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4')
|
||||||
|
|
||||||
|
describe('ISOBMFF (MP4) metadata', () => {
|
||||||
|
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const file = new File([bytes], 'test.mp4', { type: 'video/mp4' })
|
||||||
|
|
||||||
|
const result = await getFromIsobmffFile(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for non-ISOBMFF data', async () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'fake.mp4', {
|
||||||
|
type: 'video/mp4'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getFromIsobmffFile(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.mp4')
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires error', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
expect(await getFromIsobmffFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves empty when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
expect(await getFromIsobmffFile(file)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -274,6 +274,7 @@ export function getFromIsobmffFile(file: File): Promise<ComfyMetadata> {
|
|||||||
console.error('FileReader: Error reading ISOBMFF file:', err)
|
console.error('FileReader: Error reading ISOBMFF file:', err)
|
||||||
resolve({})
|
resolve({})
|
||||||
}
|
}
|
||||||
|
reader.onabort = () => resolve({})
|
||||||
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/scripts/metadata/json.test.ts
Normal file
91
src/scripts/metadata/json.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError,
|
||||||
|
mockFileReaderResult
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getDataFromJSON } from './json'
|
||||||
|
|
||||||
|
function jsonFile(content: object): File {
|
||||||
|
return new File([JSON.stringify(content)], 'test.json', {
|
||||||
|
type: 'application/json'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getDataFromJSON', () => {
|
||||||
|
it('detects API-format workflows by class_type on every value', async () => {
|
||||||
|
const apiData = {
|
||||||
|
'1': { class_type: 'KSampler', inputs: {} },
|
||||||
|
'2': { class_type: 'EmptyLatentImage', inputs: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile(apiData))
|
||||||
|
|
||||||
|
expect(result).toEqual({ prompt: apiData })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats objects without universal class_type as a workflow', async () => {
|
||||||
|
const workflow = { nodes: [], links: [], version: 1 }
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile(workflow))
|
||||||
|
|
||||||
|
expect(result).toEqual({ workflow })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts templates when the root object has a templates key', async () => {
|
||||||
|
const templates = [{ name: 'basic' }]
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile({ templates }))
|
||||||
|
|
||||||
|
expect(result).toEqual({ templates })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for non-JSON content', async () => {
|
||||||
|
const file = new File(['not valid json'], 'bad.json', {
|
||||||
|
type: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(file)
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsText')
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsText')
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined when reader.result is not a string', async () => {
|
||||||
|
mockFileReaderResult('readAsText', new ArrayBuffer(8))
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined when reader.result is null', async () => {
|
||||||
|
mockFileReaderResult('readAsText', null)
|
||||||
|
|
||||||
|
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,21 +6,28 @@ export function getDataFromJSON(
|
|||||||
return new Promise<Record<string, object> | undefined>((resolve) => {
|
return new Promise<Record<string, object> | undefined>((resolve) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const readerResult = reader.result as string
|
try {
|
||||||
const jsonContent = JSON.parse(readerResult)
|
if (typeof reader.result !== 'string') {
|
||||||
if (jsonContent?.templates) {
|
resolve(undefined)
|
||||||
resolve({ templates: jsonContent.templates })
|
return
|
||||||
return
|
}
|
||||||
|
const jsonContent = JSON.parse(reader.result)
|
||||||
|
if (jsonContent?.templates) {
|
||||||
|
resolve({ templates: jsonContent.templates })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isApiJson(jsonContent)) {
|
||||||
|
resolve({ prompt: jsonContent })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ workflow: jsonContent })
|
||||||
|
} catch {
|
||||||
|
resolve(undefined)
|
||||||
}
|
}
|
||||||
if (isApiJson(jsonContent)) {
|
|
||||||
resolve({ prompt: jsonContent })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve({ workflow: jsonContent })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
reader.onerror = () => resolve(undefined)
|
||||||
|
reader.onabort = () => resolve(undefined)
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
return
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
106
src/scripts/metadata/mp3.test.ts
Normal file
106
src/scripts/metadata/mp3.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getMp3Metadata } from './mp3'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp3')
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('MP3 metadata', () => {
|
||||||
|
it('extracts workflow and prompt from ID3 tags', async () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const file = new File([bytes], 'test.mp3', { type: 'audio/mpeg' })
|
||||||
|
|
||||||
|
const result = await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined fields when file has no embedded metadata', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const file = new File([new Uint8Array(16)], 'empty.mp3', {
|
||||||
|
type: 'audio/mpeg'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toBeUndefined()
|
||||||
|
expect(result.prompt).toBeUndefined()
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Invalid file signature.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not log an invalid signature for a valid MP3 sync header', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const buf = new Uint8Array(16)
|
||||||
|
buf[0] = 0xff
|
||||||
|
buf[1] = 0xfb
|
||||||
|
const file = new File([buf], 'valid.mp3', { type: 'audio/mpeg' })
|
||||||
|
|
||||||
|
await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not log an invalid signature for a valid ID3v2 header', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const buf = new Uint8Array(16)
|
||||||
|
buf[0] = 0x49
|
||||||
|
buf[1] = 0x44
|
||||||
|
buf[2] = 0x33
|
||||||
|
const file = new File([buf], 'valid-id3.mp3', { type: 'audio/mpeg' })
|
||||||
|
|
||||||
|
await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts metadata that spans the 4096-byte page boundary', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const metadata =
|
||||||
|
`prompt\0${JSON.stringify(EXPECTED_PROMPT)}\0` +
|
||||||
|
`workflow\0${JSON.stringify(EXPECTED_WORKFLOW)}\0`
|
||||||
|
const metadataStart = 4090
|
||||||
|
const size = metadataStart + metadata.length + 4
|
||||||
|
const buf = new Uint8Array(size)
|
||||||
|
for (let i = 0; i < metadata.length; i++) {
|
||||||
|
buf[metadataStart + i] = metadata.charCodeAt(i)
|
||||||
|
}
|
||||||
|
buf[size - 2] = 0xff
|
||||||
|
buf[size - 1] = 0xfb
|
||||||
|
const file = new File([buf], 'large.mp3', { type: 'audio/mpeg' })
|
||||||
|
|
||||||
|
const result = await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.mp3')
|
||||||
|
|
||||||
|
it('resolves undefined fields when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({ prompt: undefined, workflow: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined fields when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getMp3Metadata(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({ prompt: undefined, workflow: undefined })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
export async function getMp3Metadata(file: File) {
|
export async function getMp3Metadata(file: File) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
const read_process = new Promise(
|
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||||
(r) => (reader.onload = (event) => r(event?.target?.result))
|
reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null)
|
||||||
)
|
reader.onerror = () => r(null)
|
||||||
|
reader.onabort = () => r(null)
|
||||||
|
})
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
const arrayBuffer = (await read_process) as ArrayBuffer
|
const arrayBuffer = await read_process
|
||||||
|
if (!arrayBuffer) return { prompt: undefined, workflow: undefined }
|
||||||
//https://stackoverflow.com/questions/7302439/how-can-i-determine-that-a-particular-file-is-in-fact-an-mp3-file#7302482
|
//https://stackoverflow.com/questions/7302439/how-can-i-determine-that-a-particular-file-is-in-fact-an-mp3-file#7302482
|
||||||
const sig_bytes = new Uint8Array(arrayBuffer, 0, 3)
|
const sig_bytes = new Uint8Array(arrayBuffer, 0, 3)
|
||||||
if (
|
if (
|
||||||
(sig_bytes[0] != 0xff && sig_bytes[1] != 0xfb) ||
|
(sig_bytes[0] != 0xff || sig_bytes[1] != 0xfb) &&
|
||||||
(sig_bytes[0] != 0x49 && sig_bytes[1] != 0x44 && sig_bytes[2] != 0x33)
|
(sig_bytes[0] != 0x49 || sig_bytes[1] != 0x44 || sig_bytes[2] != 0x33)
|
||||||
)
|
)
|
||||||
console.error('Invalid file signature.')
|
console.error('Invalid file signature.')
|
||||||
let header = ''
|
let header = ''
|
||||||
while (header.length < arrayBuffer.byteLength) {
|
while (header.length < arrayBuffer.byteLength) {
|
||||||
const page = String.fromCharCode(
|
const page = String.fromCharCode(
|
||||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
...new Uint8Array(
|
||||||
|
arrayBuffer,
|
||||||
|
header.length,
|
||||||
|
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
header += page
|
header += page
|
||||||
if (page.match('\u00ff\u00fb')) break
|
if (page.match('\u00ff\u00fb')) break
|
||||||
|
|||||||
74
src/scripts/metadata/ogg.test.ts
Normal file
74
src/scripts/metadata/ogg.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXPECTED_PROMPT,
|
||||||
|
EXPECTED_WORKFLOW,
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getOggMetadata } from './ogg'
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus')
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('OGG/Opus metadata', () => {
|
||||||
|
it('extracts workflow and prompt from an Opus file', async () => {
|
||||||
|
const bytes = fs.readFileSync(fixturePath)
|
||||||
|
const file = new File([bytes], 'test.opus', { type: 'audio/ogg' })
|
||||||
|
|
||||||
|
const result = await getOggMetadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
|
||||||
|
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined fields for non-OGG data', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const file = new File([new Uint8Array(16)], 'fake.ogg', {
|
||||||
|
type: 'audio/ogg'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getOggMetadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toBeUndefined()
|
||||||
|
expect(result.prompt).toBeUndefined()
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Invalid file signature.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles files larger than 4096 bytes without RangeError', async () => {
|
||||||
|
const size = 5000
|
||||||
|
const buf = new Uint8Array(size)
|
||||||
|
const oggs = new TextEncoder().encode('OggS\0')
|
||||||
|
buf.set(oggs, 0)
|
||||||
|
buf.set(oggs, 4500)
|
||||||
|
const file = new File([buf], 'large.ogg', { type: 'audio/ogg' })
|
||||||
|
|
||||||
|
const result = await getOggMetadata(file)
|
||||||
|
|
||||||
|
expect(result.workflow).toBeUndefined()
|
||||||
|
expect(result.prompt).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileReader failure modes', () => {
|
||||||
|
const file = new File([new Uint8Array(16)], 'test.ogg')
|
||||||
|
|
||||||
|
it('resolves undefined fields when the FileReader fires error', async () => {
|
||||||
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getOggMetadata(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({ prompt: undefined, workflow: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves undefined fields when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
|
||||||
|
const result = await getOggMetadata(file)
|
||||||
|
|
||||||
|
expect(result).toEqual({ prompt: undefined, workflow: undefined })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
export async function getOggMetadata(file: File) {
|
export async function getOggMetadata(file: File) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
const read_process = new Promise(
|
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||||
(r) => (reader.onload = (event) => r(event?.target?.result))
|
reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null)
|
||||||
)
|
reader.onerror = () => r(null)
|
||||||
|
reader.onabort = () => r(null)
|
||||||
|
})
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
const arrayBuffer = (await read_process) as ArrayBuffer
|
const arrayBuffer = await read_process
|
||||||
|
if (!arrayBuffer) return { prompt: undefined, workflow: undefined }
|
||||||
const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4))
|
const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4))
|
||||||
if (signature !== 'OggS') console.error('Invalid file signature.')
|
if (signature !== 'OggS') console.error('Invalid file signature.')
|
||||||
let oggs = 0
|
let oggs = 0
|
||||||
let header = ''
|
let header = ''
|
||||||
while (header.length < arrayBuffer.byteLength) {
|
while (header.length < arrayBuffer.byteLength) {
|
||||||
const page = String.fromCharCode(
|
const page = String.fromCharCode(
|
||||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
...new Uint8Array(
|
||||||
|
arrayBuffer,
|
||||||
|
header.length,
|
||||||
|
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (page.match('OggS\u0000')) oggs++
|
if (page.match('OggS\u0000')) oggs++
|
||||||
header += page
|
header += page
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { getFromPngBuffer } from './png'
|
import {
|
||||||
|
mockFileReaderAbort,
|
||||||
|
mockFileReaderError
|
||||||
|
} from './__fixtures__/helpers'
|
||||||
|
import { getFromPngBuffer, getFromPngFile } from './png'
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||||
|
|
||||||
function createPngWithChunk(
|
function createPngWithChunk(
|
||||||
chunkType: string,
|
chunkType: string,
|
||||||
keyword: string,
|
keyword: string,
|
||||||
content: string,
|
content: string | Uint8Array,
|
||||||
options: {
|
options: {
|
||||||
compressionFlag?: number
|
compressionFlag?: number
|
||||||
compressionMethod?: number
|
compressionMethod?: number
|
||||||
@@ -20,12 +28,11 @@ function createPngWithChunk(
|
|||||||
translatedKeyword = ''
|
translatedKeyword = ''
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const signature = new Uint8Array([
|
const signature = new Uint8Array(PNG_SIGNATURE)
|
||||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
||||||
])
|
|
||||||
const typeBytes = new TextEncoder().encode(chunkType)
|
const typeBytes = new TextEncoder().encode(chunkType)
|
||||||
const keywordBytes = new TextEncoder().encode(keyword)
|
const keywordBytes = new TextEncoder().encode(keyword)
|
||||||
const contentBytes = new TextEncoder().encode(content)
|
const contentBytes =
|
||||||
|
content instanceof Uint8Array ? content : new TextEncoder().encode(content)
|
||||||
|
|
||||||
let chunkData: Uint8Array
|
let chunkData: Uint8Array
|
||||||
if (chunkType === 'iTXt') {
|
if (chunkType === 'iTXt') {
|
||||||
@@ -66,12 +73,11 @@ function createPngWithChunk(
|
|||||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||||
|
|
||||||
const crc = new Uint8Array(4)
|
const crc = new Uint8Array(4)
|
||||||
|
|
||||||
const iendType = new TextEncoder().encode('IEND')
|
const iendType = new TextEncoder().encode('IEND')
|
||||||
const iendLength = new Uint8Array(4)
|
const iendLength = new Uint8Array(4)
|
||||||
const iendCrc = new Uint8Array(4)
|
const iendCrc = new Uint8Array(4)
|
||||||
|
|
||||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
const total = signature.length + (4 + 4 + chunkData.length + 4) + (4 + 4 + 4)
|
||||||
const result = new Uint8Array(total)
|
const result = new Uint8Array(total)
|
||||||
|
|
||||||
let offset = 0
|
let offset = 0
|
||||||
@@ -138,6 +144,21 @@ describe('getFromPngBuffer', () => {
|
|||||||
expect(result['workflow']).toBe(workflow)
|
expect(result['workflow']).toBe(workflow)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs warning and skips iTXt chunk with unsupported compression method', async () => {
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const buffer = createPngWithChunk('iTXt', 'workflow', 'data', {
|
||||||
|
compressionFlag: 1,
|
||||||
|
compressionMethod: 99
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getFromPngBuffer(buffer)
|
||||||
|
|
||||||
|
expect(result['workflow']).toBeUndefined()
|
||||||
|
expect(console.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Unsupported compression method 99')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('parses compressed iTXt chunk', async () => {
|
it('parses compressed iTXt chunk', async () => {
|
||||||
const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}'
|
const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}'
|
||||||
const contentBytes = new TextEncoder().encode(workflow)
|
const contentBytes = new TextEncoder().encode(workflow)
|
||||||
@@ -163,83 +184,49 @@ describe('getFromPngBuffer', () => {
|
|||||||
pos += chunk.length
|
pos += chunk.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = createPngWithCompressedITXt(
|
const buffer = createPngWithChunk('iTXt', 'workflow', compressedBytes, {
|
||||||
'workflow',
|
compressionFlag: 1,
|
||||||
compressedBytes,
|
compressionMethod: 0
|
||||||
'',
|
})
|
||||||
''
|
|
||||||
)
|
|
||||||
const result = await getFromPngBuffer(buffer)
|
const result = await getFromPngBuffer(buffer)
|
||||||
expect(result['workflow']).toBe(workflow)
|
expect(result['workflow']).toBe(workflow)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function createPngWithCompressedITXt(
|
describe('getFromPngFile', () => {
|
||||||
keyword: string,
|
it('reads metadata from a File object', async () => {
|
||||||
compressedContent: Uint8Array,
|
const workflow = '{"nodes":[]}'
|
||||||
languageTag: string,
|
const buffer = createPngWithChunk('tEXt', 'workflow', workflow)
|
||||||
translatedKeyword: string
|
const file = new File([buffer], 'test.png', { type: 'image/png' })
|
||||||
): ArrayBuffer {
|
|
||||||
const signature = new Uint8Array([
|
|
||||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
||||||
])
|
|
||||||
const typeBytes = new TextEncoder().encode('iTXt')
|
|
||||||
const keywordBytes = new TextEncoder().encode(keyword)
|
|
||||||
const langBytes = new TextEncoder().encode(languageTag)
|
|
||||||
const transBytes = new TextEncoder().encode(translatedKeyword)
|
|
||||||
|
|
||||||
const totalLength =
|
const result = await getFromPngFile(file)
|
||||||
keywordBytes.length +
|
|
||||||
1 +
|
|
||||||
2 +
|
|
||||||
langBytes.length +
|
|
||||||
1 +
|
|
||||||
transBytes.length +
|
|
||||||
1 +
|
|
||||||
compressedContent.length
|
|
||||||
|
|
||||||
const chunkData = new Uint8Array(totalLength)
|
expect(result['workflow']).toBe(workflow)
|
||||||
let pos = 0
|
})
|
||||||
chunkData.set(keywordBytes, pos)
|
|
||||||
pos += keywordBytes.length
|
|
||||||
chunkData[pos++] = 0
|
|
||||||
chunkData[pos++] = 1
|
|
||||||
chunkData[pos++] = 0
|
|
||||||
chunkData.set(langBytes, pos)
|
|
||||||
pos += langBytes.length
|
|
||||||
chunkData[pos++] = 0
|
|
||||||
chunkData.set(transBytes, pos)
|
|
||||||
pos += transBytes.length
|
|
||||||
chunkData[pos++] = 0
|
|
||||||
chunkData.set(compressedContent, pos)
|
|
||||||
|
|
||||||
const lengthBytes = new Uint8Array(4)
|
it('returns empty for an invalid PNG File', async () => {
|
||||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const file = new File([new ArrayBuffer(8)], 'bad.png', {
|
||||||
|
type: 'image/png'
|
||||||
|
})
|
||||||
|
|
||||||
const crc = new Uint8Array(4)
|
const result = await getFromPngFile(file)
|
||||||
const iendType = new TextEncoder().encode('IEND')
|
|
||||||
const iendLength = new Uint8Array(4)
|
|
||||||
const iendCrc = new Uint8Array(4)
|
|
||||||
|
|
||||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
expect(result).toEqual({})
|
||||||
const result = new Uint8Array(total)
|
expect(console.error).toHaveBeenCalledWith('Not a valid PNG file')
|
||||||
|
})
|
||||||
|
|
||||||
let offset = 0
|
describe('FileReader failure modes', () => {
|
||||||
result.set(signature, offset)
|
const file = new File([new Uint8Array(16)], 'test.png')
|
||||||
offset += signature.length
|
|
||||||
result.set(lengthBytes, offset)
|
|
||||||
offset += 4
|
|
||||||
result.set(typeBytes, offset)
|
|
||||||
offset += 4
|
|
||||||
result.set(chunkData, offset)
|
|
||||||
offset += chunkData.length
|
|
||||||
result.set(crc, offset)
|
|
||||||
offset += 4
|
|
||||||
result.set(iendLength, offset)
|
|
||||||
offset += 4
|
|
||||||
result.set(iendType, offset)
|
|
||||||
offset += 4
|
|
||||||
result.set(iendCrc, offset)
|
|
||||||
|
|
||||||
return result.buffer
|
it('rejects when the FileReader fires error', async () => {
|
||||||
}
|
mockFileReaderError('readAsArrayBuffer')
|
||||||
|
await expect(getFromPngFile(file)).rejects.toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the FileReader fires abort', async () => {
|
||||||
|
mockFileReaderAbort('readAsArrayBuffer')
|
||||||
|
await expect(getFromPngFile(file)).rejects.toThrow('FileReader aborted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export async function getFromPngFile(
|
|||||||
resolve(result)
|
resolve(result)
|
||||||
}
|
}
|
||||||
reader.onerror = () => reject(reader.error)
|
reader.onerror = () => reject(reader.error)
|
||||||
|
reader.onabort = () => reject(new Error('FileReader aborted'))
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/scripts/metadata/svg.test.ts
Normal file
42
src/scripts/metadata/svg.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getSvgMetadata } from './svg'
|
||||||
|
|
||||||
|
function svgFile(content: string): File {
|
||||||
|
return new File([content], 'test.svg', { type: 'image/svg+xml' })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getSvgMetadata', () => {
|
||||||
|
it('extracts workflow and prompt from CDATA in <metadata>', async () => {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<metadata><![CDATA[${JSON.stringify({
|
||||||
|
workflow: { nodes: [] },
|
||||||
|
prompt: { '1': {} }
|
||||||
|
})}]]></metadata>
|
||||||
|
<rect width="1" height="1"/>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const result = await getSvgMetadata(svgFile(svg))
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
workflow: { nodes: [] },
|
||||||
|
prompt: { '1': {} }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when SVG has no metadata element', async () => {
|
||||||
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||||
|
|
||||||
|
const result = await getSvgMetadata(svgFile(svg))
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when CDATA contains invalid JSON', async () => {
|
||||||
|
const svg = `<svg><metadata><![CDATA[not valid json]]></metadata></svg>`
|
||||||
|
|
||||||
|
const result = await getSvgMetadata(svgFile(svg))
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { getFromAvifFile } from './metadata/avif'
|
import { getFromAvifFile } from './metadata/avif'
|
||||||
import { getFromFlacFile } from './metadata/flac'
|
import { getFromFlacFile } from './metadata/flac'
|
||||||
@@ -21,67 +23,183 @@ vi.mock('./metadata/avif', () => ({
|
|||||||
getFromAvifFile: vi.fn()
|
getFromAvifFile: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function buildExifPayload(workflowJson: string): Uint8Array {
|
afterEach(() => vi.restoreAllMocks())
|
||||||
const fullStr = `workflow:${workflowJson}\0`
|
|
||||||
const strBytes = new TextEncoder().encode(fullStr)
|
|
||||||
|
|
||||||
const headerSize = 22
|
const fixturesDir = path.resolve(__dirname, 'metadata/__fixtures__')
|
||||||
const buf = new Uint8Array(headerSize + strBytes.length)
|
|
||||||
|
type AsciiIfdEntry = { tag: number; value: string }
|
||||||
|
|
||||||
|
function encodeAsciiIfd(entries: AsciiIfdEntry[]): Uint8Array {
|
||||||
|
const tableSize = 10 + 12 * entries.length
|
||||||
|
const strings = entries.map((e) => new TextEncoder().encode(`${e.value}\0`))
|
||||||
|
const totalStringBytes = strings.reduce((sum, s) => sum + s.length, 0)
|
||||||
|
|
||||||
|
const buf = new Uint8Array(tableSize + totalStringBytes)
|
||||||
const dv = new DataView(buf.buffer)
|
const dv = new DataView(buf.buffer)
|
||||||
|
|
||||||
buf.set([0x49, 0x49], 0)
|
buf.set([0x49, 0x49], 0)
|
||||||
dv.setUint16(2, 0x002a, true)
|
dv.setUint16(2, 0x002a, true)
|
||||||
dv.setUint32(4, 8, true)
|
dv.setUint32(4, 8, true)
|
||||||
dv.setUint16(8, 1, true)
|
dv.setUint16(8, entries.length, true)
|
||||||
dv.setUint16(10, 0, true)
|
|
||||||
dv.setUint16(12, 2, true)
|
let stringOffset = tableSize
|
||||||
dv.setUint32(14, strBytes.length, true)
|
for (let i = 0; i < entries.length; i++) {
|
||||||
dv.setUint32(18, 22, true)
|
const entryOffset = 10 + i * 12
|
||||||
buf.set(strBytes, 22)
|
dv.setUint16(entryOffset, entries[i].tag, true)
|
||||||
|
dv.setUint16(entryOffset + 2, 2, true)
|
||||||
|
dv.setUint32(entryOffset + 4, strings[i].length, true)
|
||||||
|
dv.setUint32(entryOffset + 8, stringOffset, true)
|
||||||
|
buf.set(strings[i], stringOffset)
|
||||||
|
stringOffset += strings[i].length
|
||||||
|
}
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
|
type WebpChunk = { type: string; payload: Uint8Array }
|
||||||
const exifPayload = buildExifPayload(workflowJson)
|
|
||||||
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
|
|
||||||
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
|
|
||||||
|
|
||||||
const buffer = new Uint8Array(totalSize)
|
function wrapInWebp(chunks: WebpChunk[]): File {
|
||||||
const dv = new DataView(buffer.buffer)
|
let payloadSize = 0
|
||||||
|
for (const c of chunks) {
|
||||||
|
payloadSize += 8 + c.payload.length + (c.payload.length % 2)
|
||||||
|
}
|
||||||
|
const totalSize = 12 + payloadSize
|
||||||
|
const buf = new Uint8Array(totalSize)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
|
||||||
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
|
buf.set([0x52, 0x49, 0x46, 0x46], 0)
|
||||||
dv.setUint32(4, totalSize - 8, true)
|
dv.setUint32(4, totalSize - 8, true)
|
||||||
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
|
buf.set([0x57, 0x45, 0x42, 0x50], 8)
|
||||||
|
|
||||||
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
|
let offset = 12
|
||||||
dv.setUint32(16, precedingChunkLength, true)
|
for (const c of chunks) {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
buf[offset + i] = c.type.charCodeAt(i)
|
||||||
|
}
|
||||||
|
dv.setUint32(offset + 4, c.payload.length, true)
|
||||||
|
buf.set(c.payload, offset + 8)
|
||||||
|
offset += 8 + c.payload.length + (c.payload.length % 2)
|
||||||
|
}
|
||||||
|
|
||||||
const exifStart = 20 + precedingPadded
|
return new File([buf], 'test.webp', { type: 'image/webp' })
|
||||||
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
|
}
|
||||||
dv.setUint32(exifStart + 4, exifPayload.length, true)
|
|
||||||
buffer.set(exifPayload, exifStart + 8)
|
|
||||||
|
|
||||||
return new File([buffer], 'test.webp', { type: 'image/webp' })
|
function exifChunk(
|
||||||
|
entries: AsciiIfdEntry[],
|
||||||
|
options: { withExifPrefix?: boolean } = {}
|
||||||
|
): WebpChunk {
|
||||||
|
const ifd = encodeAsciiIfd(entries)
|
||||||
|
if (!options.withExifPrefix) {
|
||||||
|
return { type: 'EXIF', payload: ifd }
|
||||||
|
}
|
||||||
|
const prefixed = new Uint8Array(6 + ifd.length)
|
||||||
|
prefixed.set(new TextEncoder().encode('Exif\0\0'), 0)
|
||||||
|
prefixed.set(ifd, 6)
|
||||||
|
return { type: 'EXIF', payload: prefixed }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('getWebpMetadata', () => {
|
describe('getWebpMetadata', () => {
|
||||||
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
|
it('returns empty when the file is not a valid WEBP', async () => {
|
||||||
const workflow = '{"nodes":[]}'
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const file = buildWebp(3, workflow)
|
const file = new File([new Uint8Array(12)], 'fake.webp')
|
||||||
|
|
||||||
const metadata = await getWebpMetadata(file)
|
const metadata = await getWebpMetadata(file)
|
||||||
|
|
||||||
expect(metadata.workflow).toBe(workflow)
|
expect(metadata).toEqual({})
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Not a valid WEBP file')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('finds workflow when preceding chunk has even length (no padding)', async () => {
|
it('returns empty when a valid WEBP has no EXIF chunk', async () => {
|
||||||
const workflow = '{"nodes":[1]}'
|
const file = wrapInWebp([
|
||||||
const file = buildWebp(4, workflow)
|
{ type: 'VP8 ', payload: new Uint8Array([0, 0, 0, 0]) }
|
||||||
|
])
|
||||||
|
|
||||||
const metadata = await getWebpMetadata(file)
|
const metadata = await getWebpMetadata(file)
|
||||||
|
|
||||||
expect(metadata.workflow).toBe(workflow)
|
expect(metadata).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts workflow and prompt from EXIF without prefix', async () => {
|
||||||
|
const bytes = fs.readFileSync(path.join(fixturesDir, 'with_metadata.webp'))
|
||||||
|
const file = new File([bytes], 'test.webp', { type: 'image/webp' })
|
||||||
|
|
||||||
|
const metadata = await getWebpMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
workflow:
|
||||||
|
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}',
|
||||||
|
prompt: '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts workflow and prompt from EXIF with Exif\\0\\0 prefix', async () => {
|
||||||
|
const bytes = fs.readFileSync(
|
||||||
|
path.join(fixturesDir, 'with_metadata_exif_prefix.webp')
|
||||||
|
)
|
||||||
|
const file = new File([bytes], 'test.webp', { type: 'image/webp' })
|
||||||
|
|
||||||
|
const metadata = await getWebpMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
workflow:
|
||||||
|
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}',
|
||||||
|
prompt: '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('walks past odd-length preceding chunks (RIFF padding)', async () => {
|
||||||
|
const file = wrapInWebp([
|
||||||
|
{ type: 'VP8 ', payload: new Uint8Array(3) },
|
||||||
|
exifChunk([{ tag: 0, value: 'workflow:{"a":1}' }])
|
||||||
|
])
|
||||||
|
|
||||||
|
const metadata = await getWebpMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toEqual({ workflow: '{"a":1}' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getLatentMetadata', () => {
|
||||||
|
function buildSafetensors(headerObj: object): File {
|
||||||
|
const headerBytes = new TextEncoder().encode(JSON.stringify(headerObj))
|
||||||
|
const buf = new Uint8Array(8 + headerBytes.length)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
dv.setUint32(0, headerBytes.length, true)
|
||||||
|
dv.setUint32(4, 0, true)
|
||||||
|
buf.set(headerBytes, 8)
|
||||||
|
return new File([buf], 'test.safetensors')
|
||||||
|
}
|
||||||
|
|
||||||
|
it('extracts __metadata__ from a safetensors header', async () => {
|
||||||
|
const workflow =
|
||||||
|
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}'
|
||||||
|
const prompt = '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||||
|
const file = buildSafetensors({
|
||||||
|
__metadata__: { workflow, prompt },
|
||||||
|
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const metadata = await getLatentMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toEqual({ workflow, prompt })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined when the safetensors header has no __metadata__', async () => {
|
||||||
|
const file = buildSafetensors({
|
||||||
|
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const metadata = await getLatentMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for a truncated or malformed file', async () => {
|
||||||
|
const file = new File([new Uint8Array(4)], 'bad.safetensors')
|
||||||
|
|
||||||
|
const metadata = await getLatentMetadata(file)
|
||||||
|
|
||||||
|
expect(metadata).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,37 +234,3 @@ describe('format-specific metadata wrappers', () => {
|
|||||||
expect(result).toEqual({ workflow: '{"avif":1}' })
|
expect(result).toEqual({ workflow: '{"avif":1}' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildSafetensors = (header: Record<string, unknown>): File => {
|
|
||||||
const headerJson = JSON.stringify(header)
|
|
||||||
const headerBytes = new TextEncoder().encode(headerJson)
|
|
||||||
const buf = new ArrayBuffer(8 + headerBytes.length)
|
|
||||||
const dv = new DataView(buf)
|
|
||||||
dv.setUint32(0, headerBytes.length, true)
|
|
||||||
dv.setUint32(4, 0, true)
|
|
||||||
new Uint8Array(buf, 8).set(headerBytes)
|
|
||||||
return new File([buf], 'x.safetensors')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('getLatentMetadata', () => {
|
|
||||||
it('returns the __metadata__ object from a safetensors header', async () => {
|
|
||||||
const file = buildSafetensors({
|
|
||||||
__metadata__: { workflow: '{"nodes":[]}', extra: 'value' },
|
|
||||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await getLatentMetadata(file)
|
|
||||||
|
|
||||||
expect(result).toEqual({ workflow: '{"nodes":[]}', extra: 'value' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves undefined when header has no __metadata__ entry', async () => {
|
|
||||||
const file = buildSafetensors({
|
|
||||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await getLatentMetadata(file)
|
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -105,14 +105,17 @@ export function getWebpMetadata(file: File) {
|
|||||||
...webp.slice(offset, offset + 4)
|
...webp.slice(offset, offset + 4)
|
||||||
)
|
)
|
||||||
if (chunk_type === 'EXIF') {
|
if (chunk_type === 'EXIF') {
|
||||||
|
let exifOffset = offset + 8
|
||||||
|
let exifLength = chunk_length
|
||||||
if (
|
if (
|
||||||
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
|
String.fromCharCode(...webp.slice(exifOffset, exifOffset + 6)) ==
|
||||||
'Exif\0\0'
|
'Exif\0\0'
|
||||||
) {
|
) {
|
||||||
offset += 6
|
exifOffset += 6
|
||||||
|
exifLength -= 6
|
||||||
}
|
}
|
||||||
let data = parseExifData(
|
const data = parseExifData(
|
||||||
webp.slice(offset + 8, offset + 8 + chunk_length)
|
webp.slice(exifOffset, exifOffset + exifLength)
|
||||||
)
|
)
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[Number(key)]
|
const value = data[Number(key)]
|
||||||
@@ -131,30 +134,38 @@ export function getWebpMetadata(file: File) {
|
|||||||
|
|
||||||
r(txt_chunks)
|
r(txt_chunks)
|
||||||
}
|
}
|
||||||
|
reader.onerror = () => r({})
|
||||||
|
reader.onabort = () => r({})
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatentMetadata(file: File): Promise<Record<string, string>> {
|
export function getLatentMetadata(
|
||||||
|
file: File
|
||||||
|
): Promise<Record<string, string> | undefined> {
|
||||||
return new Promise((r) => {
|
return new Promise((r) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const safetensorsData = new Uint8Array(
|
try {
|
||||||
event.target?.result as ArrayBuffer
|
const safetensorsData = new Uint8Array(
|
||||||
)
|
event.target?.result as ArrayBuffer
|
||||||
const dataView = new DataView(safetensorsData.buffer)
|
|
||||||
let header_size = dataView.getUint32(0, true)
|
|
||||||
let offset = 8
|
|
||||||
let header = JSON.parse(
|
|
||||||
new TextDecoder().decode(
|
|
||||||
safetensorsData.slice(offset, offset + header_size)
|
|
||||||
)
|
)
|
||||||
)
|
const dataView = new DataView(safetensorsData.buffer)
|
||||||
r(header.__metadata__)
|
const headerSize = dataView.getUint32(0, true)
|
||||||
|
const offset = 8
|
||||||
|
const header = JSON.parse(
|
||||||
|
new TextDecoder().decode(
|
||||||
|
safetensorsData.slice(offset, offset + headerSize)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r(header.__metadata__)
|
||||||
|
} catch {
|
||||||
|
r(undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
reader.onerror = () => r(undefined)
|
||||||
var slice = file.slice(0, 1024 * 1024 * 4)
|
reader.onabort = () => r(undefined)
|
||||||
|
const slice = file.slice(0, 1024 * 1024 * 4)
|
||||||
reader.readAsArrayBuffer(slice)
|
reader.readAsArrayBuffer(slice)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user