Compare commits
19 Commits
glary/mmb-
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967bbd6ff1 | ||
|
|
b8376e2a17 | ||
|
|
55324d0bf7 | ||
|
|
3e195e6650 | ||
|
|
04918360eb | ||
|
|
af70d88860 | ||
|
|
c955309b26 | ||
|
|
7abd9d12c8 | ||
|
|
dd9cb42fa1 | ||
|
|
ccd19d8695 | ||
|
|
809fba7b36 | ||
|
|
df2ae6f2d0 | ||
|
|
3c7781190a | ||
|
|
74997e556d | ||
|
|
684261d592 | ||
|
|
1d231bd988 | ||
|
|
d370b182e7 | ||
|
|
1a8a2be4c3 | ||
|
|
026bd4d757 |
BIN
.github/pr-images/fe-237-before-after.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
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,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
@@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [
|
||||
detailPrefix: 'customers.detail.ubisoft-chord',
|
||||
readMoreHref:
|
||||
'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": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"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",
|
||||
"title": "Senior Product Designer",
|
||||
@@ -33,13 +19,6 @@
|
||||
"location": "San Francisco",
|
||||
"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",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
@@ -115,6 +94,13 @@
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"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",
|
||||
"key": "marketing",
|
||||
"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",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
@@ -144,7 +151,7 @@
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
|
||||
@@ -11,6 +11,10 @@ const translations = {
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.runFirstWorkflow': {
|
||||
en: 'Run your first workflow',
|
||||
'zh-CN': '运行你的第一个工作流'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
'showcase.subtitle1': {
|
||||
@@ -2243,6 +2247,20 @@ const translations = {
|
||||
'zh-CN':
|
||||
'育碧 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': {
|
||||
en: 'READ MORE ON THIS TOPIC',
|
||||
'zh-CN': '阅读更多相关内容'
|
||||
@@ -3276,6 +3294,227 @@ const translations = {
|
||||
'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.form.badge': {
|
||||
en: 'CONTACT SALES',
|
||||
|
||||
@@ -66,45 +66,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.drop(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle mouse button drag-and-drop. Used by the MMB pan tests across the
|
||||
* graph canvas, widget surfaces (textarea / markdown), and the mask editor
|
||||
* canvas to verify the pan gesture forwards correctly from each surface.
|
||||
*/
|
||||
async mmbDrag(
|
||||
from: Position,
|
||||
to: Position,
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle-button drag anchored at the center of a locator's bounding box.
|
||||
* Asserts visibility, resolves the center, and delegates to {@link mmbDrag}.
|
||||
* Collapses the `boundingBox()` + center-math + `mmbDrag` boilerplate that
|
||||
* repeats across MMB pan tests.
|
||||
*/
|
||||
async mmbDragFromCenter(
|
||||
locator: Locator,
|
||||
delta: { dx: number; dy: number },
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await locator.waitFor({ state: 'visible' })
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('mmbDragFromCenter: bounding box not found')
|
||||
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
await this.mmbDrag(
|
||||
start,
|
||||
{ x: start.x + delta.dx, y: start.y + delta.dy },
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/** @see {@link Mouse.move} */
|
||||
async move(to: Position, options = ComfyMouse.defaultOptions) {
|
||||
await this.mouse.move(to.x, to.y, options)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Page object for the mask editor dialog. Encapsulates the structural
|
||||
* locators that specs used to rebuild inline (undo/redo buttons, tool
|
||||
* entries, brush setting labels, etc.) so tests consume a single typed
|
||||
* surface instead of duplicating selectors.
|
||||
*/
|
||||
export class MaskEditorDialog {
|
||||
public readonly root: Locator
|
||||
public readonly heading: Locator
|
||||
|
||||
// Canvas surface
|
||||
public readonly canvasContainer: Locator
|
||||
public readonly pointerZone: Locator
|
||||
|
||||
// Header toolbar
|
||||
public readonly undoButton: Locator
|
||||
public readonly redoButton: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly cancelButton: Locator
|
||||
public readonly invertButton: Locator
|
||||
public readonly clearButton: Locator
|
||||
|
||||
// Tool panel
|
||||
public readonly toolPanel: Locator
|
||||
public readonly toolEntries: Locator
|
||||
public readonly selectedTool: Locator
|
||||
|
||||
// Brush settings side panel
|
||||
public readonly thicknessLabel: Locator
|
||||
public readonly opacityLabel: Locator
|
||||
public readonly hardnessLabel: Locator
|
||||
|
||||
constructor(public readonly comfyPage: ComfyPage) {
|
||||
const { page } = comfyPage
|
||||
this.root = page.locator('.mask-editor-dialog')
|
||||
this.heading = this.root.getByRole('heading', { name: 'Mask Editor' })
|
||||
|
||||
this.canvasContainer = this.root.locator('#maskEditorCanvasContainer')
|
||||
this.pointerZone = this.root.getByTestId('pointer-zone')
|
||||
|
||||
this.undoButton = this.root.getByRole('button', { name: 'Undo' })
|
||||
this.redoButton = this.root.getByRole('button', { name: 'Redo' })
|
||||
this.saveButton = this.root.getByRole('button', { name: 'Save' })
|
||||
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
|
||||
this.invertButton = this.root.getByRole('button', { name: 'Invert' })
|
||||
this.clearButton = this.root.getByRole('button', { name: 'Clear' })
|
||||
|
||||
this.toolPanel = this.root.locator('.maskEditor-ui-container')
|
||||
this.toolEntries = this.root.locator('.maskEditor_toolPanelContainer')
|
||||
this.selectedTool = this.root.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
|
||||
this.thicknessLabel = this.root.getByText('Thickness')
|
||||
this.opacityLabel = this.root.getByText('Opacity').first()
|
||||
this.hardnessLabel = this.root.getByText('Hardness')
|
||||
}
|
||||
|
||||
async waitForOpen(): Promise<void> {
|
||||
await expect(this.root).toBeVisible()
|
||||
await expect(this.heading).toBeVisible()
|
||||
await expect(this.canvasContainer).toBeVisible()
|
||||
await expect(this.canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
}
|
||||
|
||||
async getCanvasBoundingBox() {
|
||||
await expect(this.canvasContainer).toBeVisible()
|
||||
const box = await this.canvasContainer.boundingBox()
|
||||
if (!box)
|
||||
throw new Error('Mask editor canvas container bounding box not found')
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor off the pointer zone so PointerZone's pointerleave
|
||||
* clears store.brushVisible and the brush cursor overlay is removed from
|
||||
* the next paint. Call this before taking a canvas screenshot to avoid
|
||||
* flaky pixel diffs around the brush circle position.
|
||||
*/
|
||||
async hideBrushCursor() {
|
||||
await this.comfyPage.page.mouse.move(0, 0)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -264,18 +264,6 @@ export class CanvasHelper {
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async middleClickDrag(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
options?: { steps?: number }
|
||||
): Promise<void> {
|
||||
const { steps = 10 } = options ?? {}
|
||||
await this.page.mouse.move(from.x, from.y)
|
||||
await this.page.mouse.down({ button: 'middle' })
|
||||
await this.page.mouse.move(to.x, to.y, { steps })
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async disconnectEdge(
|
||||
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
|
||||
): Promise<void> {
|
||||
|
||||
150
browser_tests/tests/errorsTab.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function triggerExecutionError(comfyPage: {
|
||||
canvasOps: { disconnectEdge: () => Promise<void> }
|
||||
page: Page
|
||||
command: { executeCommand: (cmd: string) => Promise<void> }
|
||||
}) {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
}
|
||||
|
||||
test.describe('Errors tab in right side panel', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Errors tab appears after execution error', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
// Dismiss the error overlay
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /Dismiss/i }).click()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
await expect(
|
||||
propertiesPanel.root.getByRole('tab', { name: 'Errors' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error card shows locate button', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
const locateButton = propertiesPanel.root.getByRole('button', {
|
||||
name: 'Locate node on canvas'
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking locate button focuses canvas', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
const locateButton = propertiesPanel.root
|
||||
.getByRole('button', { name: 'Locate node on canvas' })
|
||||
.first()
|
||||
await locateButton.click()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Collapse all button collapses error groups', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
const collapseButton = propertiesPanel.root.getByRole('button', {
|
||||
name: 'Collapse all'
|
||||
})
|
||||
|
||||
// The collapse toggle only appears when there are multiple groups.
|
||||
// If only one group exists, this test verifies the button is not shown.
|
||||
const count = await collapseButton.count()
|
||||
if (count > 0) {
|
||||
await collapseButton.click()
|
||||
const expandButton = propertiesPanel.root.getByRole('button', {
|
||||
name: 'Expand all'
|
||||
})
|
||||
await expect(expandButton).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Search filters errors', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
// Search for a term that won't match any error
|
||||
await propertiesPanel.searchBox.fill('zzz_nonexistent_zzz')
|
||||
|
||||
await expect(
|
||||
propertiesPanel.root.getByRole('button', {
|
||||
name: 'Locate node on canvas'
|
||||
})
|
||||
).toHaveCount(0)
|
||||
|
||||
// Clear the search to restore results
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
|
||||
await expect(
|
||||
propertiesPanel.root
|
||||
.getByRole('button', { name: 'Locate node on canvas' })
|
||||
.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Errors tab shows error message text', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId('error-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
const errorMessage = propertiesPanel.root
|
||||
.getByTestId('error-card-message')
|
||||
.first()
|
||||
await expect(errorMessage).toBeVisible()
|
||||
await expect(errorMessage).not.toHaveText('')
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { MaskEditorDialog } from '@e2e/fixtures/components/MaskEditorDialog'
|
||||
|
||||
const OPEN_MASK_EDITOR_LABEL = 'Edit or mask image'
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
@@ -31,17 +28,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<MaskEditorDialog> {
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel(OPEN_MASK_EDITOR_LABEL).click()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const maskEditor = new MaskEditorDialog(comfyPage)
|
||||
await maskEditor.waitForOpen()
|
||||
return maskEditor
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
@@ -69,10 +72,16 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(page: Page, dialog: MaskEditorDialog) {
|
||||
await expect(dialog.pointerZone).toBeVisible()
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await dialog.pointerZone.boundingBox()
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
@@ -90,7 +99,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
async function drawStrokeAndExpectPixels(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: MaskEditorDialog
|
||||
dialog: ReturnType<typeof comfyPage.page.locator>
|
||||
) {
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await expect
|
||||
@@ -106,19 +115,24 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel(OPEN_MASK_EDITOR_LABEL).click()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = new MaskEditorDialog(comfyPage)
|
||||
await dialog.waitForOpen()
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(dialog.toolPanel).toBeVisible()
|
||||
await expect(dialog.saveButton).toBeVisible()
|
||||
await expect(dialog.cancelButton).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog.root,
|
||||
'mask-editor-dialog-open.png'
|
||||
)
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -139,11 +153,14 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = new MaskEditorDialog(comfyPage)
|
||||
await dialog.waitForOpen()
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog.root,
|
||||
dialog,
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
@@ -159,33 +176,14 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag should pan the mask editor canvas',
|
||||
{ tag: ['@screenshot', '@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await comfyMouse.mmbDragFromCenter(
|
||||
dialog.canvasContainer,
|
||||
{ dx: 140, dy: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
await dialog.hideBrushCursor()
|
||||
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog.canvasContainer,
|
||||
'mask-editor-paned-with-mmb.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
await expect(dialog.undoButton).toBeVisible()
|
||||
await dialog.undoButton.click()
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
@@ -195,12 +193,14 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
await dialog.undoButton.click()
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
|
||||
await expect(dialog.redoButton).toBeVisible()
|
||||
await dialog.redoButton.click()
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
@@ -212,8 +212,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
await expect(dialog.clearButton).toBeVisible()
|
||||
await dialog.clearButton.click()
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
@@ -223,9 +224,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
await dialog.cancelButton.click()
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
|
||||
await expect(dialog.root).toBeHidden()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
@@ -235,8 +237,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
await expect(dialog.invertButton).toBeVisible()
|
||||
await dialog.invertButton.click()
|
||||
const invertButton = dialog.getByRole('button', { name: 'Invert' })
|
||||
await expect(invertButton).toBeVisible()
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
@@ -248,7 +251,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+z')
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
@@ -259,13 +263,18 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await expect(dialog.toolPanel).toBeVisible()
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
|
||||
// The tool panel should contain exactly 5 tool entries
|
||||
await expect(dialog.toolEntries).toHaveCount(5)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// First tool (MaskPen) should be selected by default
|
||||
await expect(dialog.selectedTool).toHaveCount(1)
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -274,16 +283,20 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await expect(dialog.toolEntries).toHaveCount(5)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// Click the third tool (Eraser, index 2)
|
||||
await dialog.toolEntries.nth(2).click()
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// The third tool should now be selected
|
||||
await expect(dialog.selectedTool).toHaveCount(1)
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
|
||||
// Verify it's the eraser (3rd entry)
|
||||
await expect(dialog.toolEntries.nth(2)).toHaveClass(/Selected/)
|
||||
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
@@ -292,9 +305,14 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
await expect(dialog.thicknessLabel).toBeVisible()
|
||||
await expect(dialog.opacityLabel).toBeVisible()
|
||||
await expect(dialog.hardnessLabel).toBeVisible()
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
await expect(thicknessLabel).toBeVisible()
|
||||
|
||||
const opacityLabel = dialog.getByText('Opacity').first()
|
||||
await expect(opacityLabel).toBeVisible()
|
||||
|
||||
const hardnessLabel = dialog.getByText('Hardness')
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
|
||||
@@ -328,21 +346,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(dialog.saveButton).toBeVisible()
|
||||
await dialog.saveButton.click()
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeVisible()
|
||||
await saveButton.click()
|
||||
|
||||
await expect(dialog.root).toBeHidden()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads the mask plus at least one image layer.
|
||||
// Pinning >=1 of each catches regressions where either branch silently
|
||||
// short-circuits. Poll because `dialog.root` can hide before both route
|
||||
// handlers have actually incremented their counters on CI.
|
||||
await expect
|
||||
.poll(() => maskUploadCount, { message: 'mask upload should fire' })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => imageUploadCount, { message: 'image upload should fire' })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage }) => {
|
||||
@@ -356,10 +370,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
await dialog.saveButton.click()
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
// Dialog should remain open when save fails
|
||||
await expect(dialog.root).toBeVisible()
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -374,7 +389,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
await dialog.toolEntries.nth(2).click()
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
|
Before Width: | Height: | Size: 267 KiB |
57
browser_tests/tests/settingsSidebar.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Settings Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Settings button is visible in sidebar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await expect(settingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking settings button opens settings dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await settingsButton.click()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('Settings dialog shows categories', async ({ comfyPage }) => {
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await settingsButton.click()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.categories.first()).toBeVisible()
|
||||
expect(await comfyPage.settingDialog.categories.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Settings dialog can be closed with Escape', async ({ comfyPage }) => {
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await settingsButton.click()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.settingDialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Settings search box is functional', async ({ comfyPage }) => {
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await settingsButton.click()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await comfyPage.settingDialog.searchBox.fill('color')
|
||||
await expect(comfyPage.settingDialog.searchBox).toHaveValue('color')
|
||||
})
|
||||
|
||||
test('Settings dialog can navigate to About panel', async ({ comfyPage }) => {
|
||||
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
|
||||
await settingsButton.click()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await comfyPage.settingDialog.goToAboutPanel()
|
||||
await expect(comfyPage.page.locator('.about-container')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Slot Auto Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.MiddleClickRerouteNode',
|
||||
true
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.MiddleClickRerouteNode',
|
||||
false
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Middle-click on output slot should create default node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const [nodeRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
nodeRef,
|
||||
'Expected CLIPTextEncode node in default workflow'
|
||||
).toBeTruthy()
|
||||
|
||||
if (!nodeRef)
|
||||
throw new Error('Expected CLIPTextEncode node in default workflow')
|
||||
|
||||
const slotPos = await comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph!.getNodeById(targetNodeId)
|
||||
if (!node) return null
|
||||
const pos = node.getConnectionPos(false, 0)
|
||||
return window.app!.canvasPosToClientPos(pos)
|
||||
}, nodeRef.id)
|
||||
if (!slotPos) throw new Error('Could not resolve output slot position')
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.page.mouse.click(slotPos[0], slotPos[1], {
|
||||
button: 'middle'
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(initialNodeCount)
|
||||
})
|
||||
})
|
||||
@@ -17,23 +17,4 @@ test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Middle-click drag on node should pan canvas',
|
||||
{ tag: ['@screenshot', '@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const node = comfyPage.vueNodes
|
||||
.getNodeByTitle('CLIP Text Encode (Prompt)')
|
||||
.first()
|
||||
await comfyMouse.mmbDragFromCenter(
|
||||
node,
|
||||
{ dx: 140, dy: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-mmb-over-node.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB |
69
browser_tests/tests/vueNodes/nodeHeaderActions.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Header Actions', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Collapse button is visible on node header', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.collapseButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking collapse button hides node body', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.body).toBeVisible()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(vueNode.body).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clicking collapse button again expands node', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(vueNode.body).toBeHidden()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(vueNode.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('Double-click header enters title edit mode', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await vueNode.header.dblclick()
|
||||
await vueNode.titleEditor.expectVisible()
|
||||
})
|
||||
|
||||
test('Title edit saves on Enter', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await vueNode.setTitle('My Custom Sampler')
|
||||
expect(await vueNode.getTitle()).toBe('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('Title edit cancels on Escape', async ({ comfyPage }) => {
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await vueNode.setTitle('Renamed Sampler')
|
||||
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
|
||||
|
||||
await vueNode.header.dblclick()
|
||||
await vueNode.titleEditor.input.fill('This Should Be Cancelled')
|
||||
await vueNode.titleEditor.cancel()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
|
||||
})
|
||||
})
|
||||
@@ -54,40 +54,4 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
|
||||
await textarea.click({ button: 'right' })
|
||||
await expect(vueContextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag on textarea should pan canvas',
|
||||
{ tag: ['@screenshot', '@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
await comfyMouse.mmbDragFromCenter(
|
||||
textarea,
|
||||
{ dx: 120, dy: 80 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-mmb-over-textarea.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Middle-click drag on markdown widget should pan canvas',
|
||||
{ tag: ['@screenshot', '@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const markdownWidget = comfyPage.page.locator('.widget-markdown').first()
|
||||
await comfyMouse.mmbDragFromCenter(
|
||||
markdownWidget,
|
||||
{ dx: 120, dy: 80 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-mmb-over-markdown.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 96 KiB |
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.')
|
||||
@@ -1,274 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isMiddleButtonEvent,
|
||||
isMiddleButtonHeld,
|
||||
isMiddleForPointerEvent,
|
||||
isMiddlePointerInput
|
||||
} from '@/base/pointerUtils'
|
||||
|
||||
describe('isMiddlePointerInput', () => {
|
||||
describe('MouseEvent.button semantics (down/up events)', () => {
|
||||
it('returns true when button is 1 (middle)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 1 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when button is 0 (left)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 0 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when button is 2 (right)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 2 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MouseEvent.buttons semantics (move events)', () => {
|
||||
it('returns true when buttons bitmask is exactly 4 (middle only)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 4 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when buttons bitmask is 0 (no buttons held)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 0 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when buttons bitmask is 1 (left only)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 1 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when buttons bitmask is 2 (right only)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 2 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the event has no button state', () => {
|
||||
const event = {} as MouseEvent
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chorded buttons (strict equality, not bitmask)', () => {
|
||||
it('returns false when middle+left are held simultaneously (buttons=5)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 5 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when middle+right are held simultaneously (buttons=6)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 6 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when all three buttons are held (buttons=7)', () => {
|
||||
const event = new MouseEvent('mousemove', { button: 0, buttons: 7 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PointerEvent', () => {
|
||||
it('returns true for pointerdown with button === 1', () => {
|
||||
const event = new PointerEvent('pointerdown', { button: 1 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for pointermove with buttons === 4', () => {
|
||||
const event = new PointerEvent('pointermove', { button: 0, buttons: 4 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for pointerdown with button === 0', () => {
|
||||
const event = new PointerEvent('pointerdown', { button: 0 })
|
||||
expect(isMiddlePointerInput(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('button takes precedence over buttons on down/up events', () => {
|
||||
// On pointerdown with button===1, buttons typically also contains 4, but we
|
||||
// want to confirm that the 'button' branch wins even when 'buttons'
|
||||
// disagrees (e.g., synthetic events in tests, or quirky UA behavior).
|
||||
it('returns true when button===1 even if buttons does not include middle', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 1, buttons: 0 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when button===1 even if buttons reports a different chord', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 1, buttons: 2 })
|
||||
expect(isMiddlePointerInput(event)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMiddleButtonHeld', () => {
|
||||
it('returns true when middle is the only held button (buttons=4)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 4 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when middle is held chorded with left (buttons=5)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 5 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when middle is held chorded with right (buttons=6)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 6 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when all three buttons are held (buttons=7)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 7 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when only left is held (buttons=1)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 1 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when only right is held (buttons=2)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 2 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no buttons are held (buttons=0)', () => {
|
||||
const event = new MouseEvent('mousemove', { buttons: 0 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores button field — only buttons (held) matters', () => {
|
||||
// Synthetic: pointerdown with button===1 but buttons=0 (quirky UA). Held
|
||||
// semantics say middle is NOT currently held, so false.
|
||||
const event = new MouseEvent('mousedown', { button: 1, buttons: 0 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('works for PointerEvent with buttons=4', () => {
|
||||
const event = new PointerEvent('pointermove', { buttons: 4 })
|
||||
expect(isMiddleButtonHeld(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the event has no buttons bitmask', () => {
|
||||
const event = {} as MouseEvent
|
||||
expect(isMiddleButtonHeld(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMiddleButtonEvent', () => {
|
||||
it('returns true when button is 1 (middle)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 1 })
|
||||
expect(isMiddleButtonEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true on pointerup with button=1 even if buttons=0', () => {
|
||||
// On middle pointerup the button just released, so buttons typically
|
||||
// drops middle. Use the button field to identify middle-up events.
|
||||
const event = new PointerEvent('pointerup', { button: 1, buttons: 0 })
|
||||
expect(isMiddleButtonEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when button is 0 (left)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 0 })
|
||||
expect(isMiddleButtonEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when button is 2 (right)', () => {
|
||||
const event = new MouseEvent('mousedown', { button: 2 })
|
||||
expect(isMiddleButtonEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores buttons bitmask — only button field matters', () => {
|
||||
// buttons=5 (middle held while left press fires) but button=0 means this
|
||||
// is a left-button event, not a middle-button event.
|
||||
const event = new MouseEvent('mousedown', { button: 0, buttons: 5 })
|
||||
expect(isMiddleButtonEvent(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMiddleForPointerEvent', () => {
|
||||
it('dispatches pointerdown through isMiddlePointerInput (strict buttons)', () => {
|
||||
// Middle-only pointerdown → true
|
||||
expect(
|
||||
isMiddleForPointerEvent(
|
||||
new PointerEvent('pointerdown', { button: 1, buttons: 4 })
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Chorded pointerdown (left pressed while middle is incidentally held) →
|
||||
// strict semantics reject; must NOT forward as middle.
|
||||
expect(
|
||||
isMiddleForPointerEvent(
|
||||
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('dispatches pointermove through isMiddleButtonHeld (bitmask)', () => {
|
||||
// Middle-only move → true
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
).toBe(true)
|
||||
|
||||
// Chorded move (middle + left, middle + right, all three) → still held,
|
||||
// forwarding must survive the chord.
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 5 }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 6 }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 7 }))
|
||||
).toBe(true)
|
||||
|
||||
// No middle bit → false
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 1 }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('dispatches pointerup through isMiddleButtonEvent (button field)', () => {
|
||||
// Middle released, buttons already dropped middle — must still identify
|
||||
// this as a middle event via `button`.
|
||||
expect(
|
||||
isMiddleForPointerEvent(
|
||||
new PointerEvent('pointerup', { button: 1, buttons: 0 })
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Non-middle pointerup → false
|
||||
expect(
|
||||
isMiddleForPointerEvent(
|
||||
new PointerEvent('pointerup', { button: 0, buttons: 0 })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to isMiddleButtonEvent for other event types (e.g. auxclick)', () => {
|
||||
expect(
|
||||
isMiddleForPointerEvent(new MouseEvent('auxclick', { button: 1 }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
isMiddleForPointerEvent(new MouseEvent('auxclick', { button: 2 }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('dispatches pointercancel through isMiddleButtonHeld (button field is -1 per spec)', () => {
|
||||
// Per the Pointer Events spec, pointercancel always carries
|
||||
// `button === -1` because no button state changed. Identifying a
|
||||
// middle-button cancel has to come from the `buttons` bitmask instead.
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 4 }))
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 5 }))
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 1 }))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if a pointer or mouse event is a middle button input.
|
||||
*
|
||||
* Uses strict `buttons === 4` on the move branch so that chorded pointerdown
|
||||
* events (e.g., left-click while middle is incidentally held) are not
|
||||
* misclassified as middle-button clicks. For "is the middle button currently
|
||||
* held regardless of other buttons" semantics (typical for pointermove panning
|
||||
* or held-state indicators), use {@link isMiddleButtonHeld} instead.
|
||||
*
|
||||
* Checks if a pointer or mouse event is a middle button input
|
||||
* @param event - The pointer or mouse event to check
|
||||
* @returns true if the event is from the middle button/wheel
|
||||
*/
|
||||
@@ -27,62 +20,3 @@ export function isMiddlePointerInput(
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the middle button is currently held, using a bitmask so chorded
|
||||
* states (middle + left, middle + right, etc.) still register as held.
|
||||
*
|
||||
* Use this on pointermove-style handlers that want to keep a middle-button
|
||||
* gesture alive while other buttons transition. Do NOT use on pointerdown
|
||||
* where a freshly-pressed left button while middle is held would otherwise be
|
||||
* misclassified as middle input — use {@link isMiddlePointerInput} there.
|
||||
*/
|
||||
export function isMiddleButtonHeld(event: PointerEvent | MouseEvent): boolean {
|
||||
if ('buttons' in event && typeof event.buttons === 'number') {
|
||||
return (event.buttons & 4) === 4
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the event's `button` field identifies the middle button —
|
||||
* i.e. the event was caused by a middle-button press/release/auxclick. Does
|
||||
* not consult the `buttons` bitmask.
|
||||
*
|
||||
* Use this on state-transition handlers (pointerdown, pointerup, auxclick)
|
||||
* where `button` is the authoritative source. pointerup in particular cannot
|
||||
* use {@link isMiddleButtonHeld} because the button has just been released
|
||||
* and no longer appears in `buttons`.
|
||||
*/
|
||||
export function isMiddleButtonEvent(event: PointerEvent | MouseEvent): boolean {
|
||||
return 'button' in event && event.button === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches between the three middle-button predicates based on the event's
|
||||
* type, so a single handler bound to multiple pointer events picks the right
|
||||
* semantic per event:
|
||||
*
|
||||
* - pointerdown → {@link isMiddlePointerInput} (strict, rejects chorded
|
||||
* pointerdowns where middle is only incidentally held)
|
||||
* - pointermove and pointercancel → {@link isMiddleButtonHeld} (bitmask,
|
||||
* keeps a chorded drag alive when the user adds/removes other buttons
|
||||
* mid-gesture; pointercancel reports `button = -1` per spec so the
|
||||
* `button` field cannot be used to identify a middle-button cancel)
|
||||
* - pointerup and everything else → {@link isMiddleButtonEvent} (`button`
|
||||
* field, the only reliable source on release)
|
||||
*
|
||||
* Use this at sites that wire the same callback to pointerdown, pointermove,
|
||||
* and pointerup together (e.g. capture-phase forwarders). Handlers that only
|
||||
* care about a single event type should call the specific helper directly.
|
||||
*/
|
||||
export function isMiddleForPointerEvent(
|
||||
event: PointerEvent | MouseEvent
|
||||
): boolean {
|
||||
if (event.type === 'pointerdown') return isMiddlePointerInput(event)
|
||||
if (event.type === 'pointermove' || event.type === 'pointercancel') {
|
||||
return isMiddleButtonHeld(event)
|
||||
}
|
||||
return isMiddleButtonEvent(event)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
@@ -124,7 +119,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -189,7 +184,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
@@ -605,11 +599,7 @@ onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
// Bound to pointerdown, pointerup, AND pointermove (see template capture
|
||||
// handlers). isMiddleForPointerEvent picks the right helper per event type
|
||||
// so the forwarder survives chorded moves without misclassifying chorded
|
||||
// pointerdowns.
|
||||
if (!isMiddleForPointerEvent(e)) return
|
||||
if (!isMiddlePointerInput(e)) return
|
||||
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
|
||||
return
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<p
|
||||
v-if="error.message"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
data-testid="error-card-message"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
51
src/components/toast/ProgressToastItem.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
progressToast: {
|
||||
finished: 'Finished',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function completedJob(): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'controlnet-canny.safetensors',
|
||||
bytesTotal: 100,
|
||||
bytesDownloaded: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProgressToastItem — completed state', () => {
|
||||
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
|
||||
render(ProgressToastItem, {
|
||||
props: { job: completedJob() },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
const badge = screen.getByText('Finished')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(badge.closest('.opacity-50')).toBeNull()
|
||||
|
||||
const assetName = screen.getByText('controlnet-canny.safetensors')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(assetName.closest('.opacity-50')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,9 @@ const isPending = computed(() => job.status === 'created')
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
|
||||
@@ -440,13 +440,6 @@ describe('useToolManager', () => {
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not pan on chorded middle and left button drag', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 5 }))
|
||||
|
||||
expect(mockPanZoom.handlePanMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pan on left button + space drag', async () => {
|
||||
const tm = setup()
|
||||
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useCanvasTools } from './useCanvasTools'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
import type { useKeyboard } from './useKeyboard'
|
||||
import type { usePanAndZoom } from './usePanAndZoom'
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function useToolManager(
|
||||
@@ -111,15 +110,6 @@ export function useToolManager(
|
||||
}
|
||||
)
|
||||
|
||||
// Pan gate shared by pointerdown and pointermove: middle-button (strict so
|
||||
// chorded pointerdown on a non-middle button is not misclassified) OR
|
||||
// space-held left-drag. Extracting keeps both handlers as a single
|
||||
// early-exit against one named contract instead of a repeated boolean.
|
||||
const shouldPan = (event: PointerEvent): boolean => {
|
||||
if (isMiddlePointerInput(event)) return true
|
||||
return event.buttons === 1 && keyboard.isKeyDown(' ')
|
||||
}
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (event.pointerType === 'touch') return
|
||||
@@ -128,14 +118,18 @@ export function useToolManager(
|
||||
panZoom.addPenPointerId(event.pointerId)
|
||||
}
|
||||
|
||||
if (shouldPan(event)) {
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
panZoom.handlePanStart(event)
|
||||
|
||||
store.brushVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
if (store.currentTool === Tools.PaintPen && event.button === 0) {
|
||||
await brushDrawing.startDrawing(event)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,6 +166,7 @@ export function useToolManager(
|
||||
|
||||
if ([0, 2].includes(event.button) && isDrawingTool) {
|
||||
await brushDrawing.startDrawing(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +177,9 @@ export function useToolManager(
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
|
||||
if (shouldPan(event)) {
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
await panZoom.handlePanMove(event)
|
||||
return
|
||||
}
|
||||
@@ -207,6 +204,7 @@ export function useToolManager(
|
||||
|
||||
if (event.buttons === 1 || event.buttons === 2) {
|
||||
await brushDrawing.handleDrawing(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,16 @@ function createMeshModel(name = 'TestModel'): THREE.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('constructor', () => {
|
||||
it('initializes default state', () => {
|
||||
@@ -311,6 +321,20 @@ describe('SceneModelManager', () => {
|
||||
expect(geoDispose).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', () => {
|
||||
|
||||
@@ -328,7 +328,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.scene.remove(obj)
|
||||
|
||||
obj.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
|
||||
child.geometry?.dispose()
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { isMiddleButtonEvent, isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
|
||||
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
@@ -1861,6 +1860,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const { graph } = this
|
||||
if (newGraph === graph) return
|
||||
|
||||
// Drop any in-flight ghost so listeners don't outlive the graph it belongs to
|
||||
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
|
||||
|
||||
this.clear()
|
||||
@@ -1973,11 +1973,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
/** Prevents default for middle-click auxclick only. */
|
||||
_preventMiddleAuxClick(e: MouseEvent): void {
|
||||
// Gate on the released button, not the held bitmask. On a non-middle
|
||||
// auxclick (e.g. right-button release), `buttons` may still include the
|
||||
// middle bit if middle is held, which would false-positive through
|
||||
// isMiddlePointerInput and suppress defaults for unrelated auxclicks.
|
||||
if (isMiddleButtonEvent(e)) e.preventDefault()
|
||||
if (e.button === 1) e.preventDefault()
|
||||
}
|
||||
|
||||
/** Captures an event and prevents default - returns true. */
|
||||
@@ -2303,7 +2299,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// left button mouse / single finger
|
||||
if (e.button === 0 && !pointer.isDouble) {
|
||||
this._processPrimaryButton(e, node)
|
||||
} else if (isMiddlePointerInput(e)) {
|
||||
} else if (e.button === 1) {
|
||||
this._processMiddleButton(e, node)
|
||||
} else if (
|
||||
(e.button === 2 || pointer.isDouble) &&
|
||||
@@ -3670,6 +3666,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param dragEvent Optional mouse event for positioning under cursor
|
||||
*/
|
||||
startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void {
|
||||
// Cancel any in-flight ghost so we don't leak its listeners
|
||||
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
|
||||
|
||||
this.emitBeforeChange()
|
||||
@@ -3712,11 +3709,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this._ghostPointerHandler
|
||||
)
|
||||
|
||||
// Listen on document so cancellation works even when the canvas isnt focused
|
||||
// e.g. the search dialog just closed.
|
||||
// stopPropagation prevents window-level keybindings (like Comfy.Graph.ExitSubgraph on Escape) from firing alongside the cancel.
|
||||
this._ghostKeyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' && e.key !== 'Delete' && e.key !== 'Backspace') {
|
||||
return
|
||||
}
|
||||
|
||||
this.finalizeGhostPlacement(true)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
@@ -3849,7 +3848,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this
|
||||
)
|
||||
}
|
||||
} else if (isMiddleButtonEvent(e)) {
|
||||
} else if (e.button === 1) {
|
||||
// middle button
|
||||
this.dirty_canvas = true
|
||||
this.dragging_canvas = false
|
||||
} else if (e.button === 2) {
|
||||
@@ -3943,17 +3943,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const { graph } = this
|
||||
if (!graph) return
|
||||
|
||||
// Cancel ghost placement
|
||||
if (
|
||||
(e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') &&
|
||||
this.state.ghostNodeId != null
|
||||
) {
|
||||
this.finalizeGhostPlacement(true)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
let block_default = false
|
||||
// @ts-expect-error EventTarget.localName is not in standard types
|
||||
if (e.target.localName == 'input') return
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { InputIndicators } from '@/lib/litegraph/src/canvas/InputIndicators'
|
||||
|
||||
// Minimal LGraphCanvas-shaped fake good enough for InputIndicators' constructor
|
||||
// and the handlers we exercise. The class touches `canvas.canvas` (DOM element),
|
||||
// `canvas.drawFrontCanvas`, and `canvas.setDirty`; nothing else on the handler
|
||||
// paths under test.
|
||||
function createFakeCanvas() {
|
||||
const element = document.createElement('canvas')
|
||||
const origDrawFrontCanvas = vi.fn()
|
||||
return {
|
||||
canvas: element,
|
||||
drawFrontCanvas: origDrawFrontCanvas,
|
||||
setDirty: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
describe('InputIndicators.onPointerDownOrMove', () => {
|
||||
let canvas: LGraphCanvas
|
||||
let indicators: InputIndicators
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = createFakeCanvas()
|
||||
indicators = new InputIndicators(canvas)
|
||||
})
|
||||
|
||||
it('flags mouse1Down when only middle is held (buttons=4)', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 4 })
|
||||
)
|
||||
|
||||
expect(indicators.mouse0Down).toBe(false)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
expect(indicators.mouse2Down).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps mouse1Down while middle is chorded with left (buttons=5)', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 5 })
|
||||
)
|
||||
|
||||
expect(indicators.mouse0Down).toBe(true)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
expect(indicators.mouse2Down).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps mouse1Down while middle is chorded with right (buttons=6)', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 6 })
|
||||
)
|
||||
|
||||
expect(indicators.mouse0Down).toBe(false)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
expect(indicators.mouse2Down).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps mouse1Down while all three buttons are held (buttons=7)', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 7 })
|
||||
)
|
||||
|
||||
expect(indicators.mouse0Down).toBe(true)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
expect(indicators.mouse2Down).toBe(true)
|
||||
})
|
||||
|
||||
it('clears mouse1Down when middle is not in buttons (left only, buttons=1)', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 1 })
|
||||
)
|
||||
|
||||
expect(indicators.mouse0Down).toBe(true)
|
||||
expect(indicators.mouse1Down).toBe(false)
|
||||
expect(indicators.mouse2Down).toBe(false)
|
||||
})
|
||||
|
||||
it('clears all flags when no buttons are held (buttons=0)', () => {
|
||||
// Prime with middle held, then send a no-buttons event (e.g., after release).
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 4 })
|
||||
)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 0 })
|
||||
)
|
||||
expect(indicators.mouse0Down).toBe(false)
|
||||
expect(indicators.mouse1Down).toBe(false)
|
||||
expect(indicators.mouse2Down).toBe(false)
|
||||
})
|
||||
|
||||
it('captures the pointer position and marks canvas dirty', () => {
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { clientX: 123, clientY: 456, buttons: 4 })
|
||||
)
|
||||
|
||||
expect(indicators.x).toBe(123)
|
||||
expect(indicators.y).toBe(456)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('InputIndicators.onPointerUp', () => {
|
||||
it('clears all mouse-down flags', () => {
|
||||
const canvas = createFakeCanvas()
|
||||
const indicators = new InputIndicators(canvas)
|
||||
|
||||
indicators.onPointerDownOrMove(
|
||||
new MouseEvent('pointermove', { buttons: 7 })
|
||||
)
|
||||
expect(indicators.mouse1Down).toBe(true)
|
||||
|
||||
indicators.onPointerUp()
|
||||
|
||||
expect(indicators.mouse0Down).toBe(false)
|
||||
expect(indicators.mouse1Down).toBe(false)
|
||||
expect(indicators.mouse2Down).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isMiddleButtonHeld } from '@/base/pointerUtils'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
|
||||
/**
|
||||
@@ -6,12 +5,10 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
*
|
||||
* Used to create videos of feature changes.
|
||||
*
|
||||
* Example usage with ComfyUI_frontend, via console / devtools. The class is
|
||||
* reachable as `LiteGraph.Classes.InputIndicators`, not at the top level of
|
||||
* `LiteGraph`:
|
||||
* Example usage with ComfyUI_frontend, via console / devtools:
|
||||
*
|
||||
* ```ts
|
||||
* const inputIndicators = new LiteGraph.Classes.InputIndicators(window.app.canvas)
|
||||
* const inputIndicators = new InputIndicators(canvas)
|
||||
* // Dispose:
|
||||
* inputIndicators.dispose()
|
||||
* ```
|
||||
@@ -74,7 +71,7 @@ export class InputIndicators implements Disposable {
|
||||
private _onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
|
||||
onPointerDownOrMove(e: MouseEvent): void {
|
||||
this.mouse0Down = (e.buttons & 1) === 1
|
||||
this.mouse1Down = isMiddleButtonHeld(e)
|
||||
this.mouse1Down = (e.buttons & 4) === 4
|
||||
this.mouse2Down = (e.buttons & 2) === 2
|
||||
|
||||
this.x = e.clientX
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
assetService,
|
||||
isBlake3AssetHash,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
@@ -44,6 +49,10 @@ vi.mock('@/i18n', () => ({
|
||||
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
|
||||
const validBlake3Hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
|
||||
|
||||
function buildResponse(
|
||||
body: unknown,
|
||||
init: { ok?: boolean; status?: number } = {}
|
||||
@@ -180,9 +189,98 @@ describe(assetService.getAssetMetadata, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(isBlake3AssetHash, () => {
|
||||
it('accepts only prefixed 64-character blake3 hashes', () => {
|
||||
expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
|
||||
expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
|
||||
true
|
||||
)
|
||||
expect(isBlake3AssetHash('blake3:abc')).toBe(false)
|
||||
expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(toBlake3AssetHash, () => {
|
||||
it('normalizes 64-character blake3 hex values to asset hashes', () => {
|
||||
expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
|
||||
expect(toBlake3AssetHash('abc')).toBeNull()
|
||||
expect(toBlake3AssetHash(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetFromUrl, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets when the upload response is invalid', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requires upload responses to include created_new', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns validated upload responses with created_new', async () => {
|
||||
const uploadedAsset = {
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
created_new: true
|
||||
}
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).resolves.toEqual(uploadedAsset)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetFromBase64, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('throws before calling the network when data is not a data URL', async () => {
|
||||
@@ -195,6 +293,63 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
|
||||
expect(fetchApiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets when the upload response is invalid', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromBase64({
|
||||
data: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'input.txt',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
fetchSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects upload responses with a non-boolean created_new', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
created_new: 'true'
|
||||
})
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromBase64({
|
||||
data: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'input.txt',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
fetchSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetAsync, () => {
|
||||
@@ -354,3 +509,391 @@ describe(assetService.getAssetsByTag, () => {
|
||||
expect(params.get('include_public')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getAllAssetsByTag, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'c', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['a', 'b', 'c'])
|
||||
|
||||
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
|
||||
expect(firstParams.get('include_public')).toBe('true')
|
||||
expect(firstParams.get('limit')).toBe('2')
|
||||
expect(firstParams.has('offset')).toBe(false)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('include_public')).toBe('true')
|
||||
expect(secondParams.get('limit')).toBe('2')
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('paginates from raw response size before filtering missing-tagged assets', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
|
||||
if (typeof secondUrl !== 'string') {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
has_more: true
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
|
||||
has_more: false
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 3
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
|
||||
if (typeof secondUrl !== 'string') {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('passes abort signals through paginated requests', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(expect.any(String), {
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
|
||||
it('stops pagination when aborted between pages', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('loads input assets with public assets included and reuses the cache', async () => {
|
||||
const assets = [
|
||||
validAsset({ id: 'user-input', tags: ['input'] }),
|
||||
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
|
||||
]
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
|
||||
|
||||
const first = await assetService.getInputAssetsIncludingPublic()
|
||||
const second = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(first).toEqual(assets)
|
||||
expect(second).toBe(first)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_public')).toBe('true')
|
||||
expect(params.get('limit')).toBe('500')
|
||||
})
|
||||
|
||||
it('fetches fresh input assets after explicit invalidation', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let one caller abort the shared input asset load for other callers', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
let serviceSignal: AbortSignal | undefined
|
||||
fetchApiMock.mockImplementationOnce(async (_url, options) => {
|
||||
serviceSignal = options?.signal ?? undefined
|
||||
return await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(serviceSignal).toBeUndefined()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
|
||||
await expect(second).resolves.toEqual(assets)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the shared input asset load alive after all callers abort', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
fetchApiMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
assets
|
||||
)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not abort in-flight input asset loads when invalidated', async () => {
|
||||
const assets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
fetchApiMock
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
const inFlight = assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
|
||||
await expect(inFlight).resolves.toEqual(assets)
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
freshAssets
|
||||
)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidates cached input assets after deleting an asset', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(null))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.deleteAsset('stale-input')
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchApiMock.mock.calls[1]).toEqual([
|
||||
'/assets/stale-input',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates cached input assets after an input asset upload', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
|
||||
const freshAssets = [uploadedAsset]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets for pending async input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(
|
||||
{ task_id: 'task-1', status: 'running' },
|
||||
{ ok: true, status: 202 }
|
||||
)
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets for non-input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/model.safetensors',
|
||||
tags: ['models']
|
||||
})
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.checkAssetHash, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[200, 'exists'],
|
||||
[404, 'missing'],
|
||||
[400, 'invalid']
|
||||
] as const)('maps %s responses to %s', async (status, expected) => {
|
||||
const hash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
|
||||
|
||||
await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
`/assets/hash/${encodeURIComponent(hash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for unexpected responses', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
|
||||
|
||||
await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
|
||||
'Unexpected asset hash check status: 500'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
|
||||
@@ -29,9 +30,14 @@ export interface PaginationOptions {
|
||||
offset?: number
|
||||
}
|
||||
|
||||
interface AssetPaginationOptions extends PaginationOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface AssetRequestOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
includePublic?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface AssetExportOptions {
|
||||
@@ -170,10 +176,61 @@ const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
|
||||
/** Result of a HEAD lookup against an exact asset hash. */
|
||||
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
|
||||
|
||||
const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
|
||||
const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
|
||||
const uploadedAssetResponseSchema = assetItemSchema.extend({
|
||||
created_new: z.boolean()
|
||||
})
|
||||
|
||||
/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
|
||||
export function isBlake3AssetHash(value: string): boolean {
|
||||
return BLAKE3_ASSET_HASH_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
|
||||
export function toBlake3AssetHash(hash: string | undefined): string | null {
|
||||
if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
|
||||
return `blake3:${hash}`
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
throwIfAborted(signal)
|
||||
if (!signal) return await promise
|
||||
|
||||
let removeAbortListener = () => {}
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
const onAbort = () => reject(createAbortError())
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
removeAbortListener = () => signal.removeEventListener('abort', onAbort)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, abortPromise])
|
||||
} finally {
|
||||
removeAbortListener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates asset response data using Zod schema
|
||||
*/
|
||||
@@ -187,11 +244,43 @@ function validateAssetResponse(data: unknown): AssetResponse {
|
||||
)
|
||||
}
|
||||
|
||||
function validateUploadedAssetResponse(
|
||||
data: unknown
|
||||
): AssetItem & { created_new: boolean } {
|
||||
const result = uploadedAssetResponseSchema.safeParse(data)
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
|
||||
console.error('Invalid asset upload response:', fromZodError(result.error))
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private service for asset-related network requests
|
||||
* Not exposed globally - used internally by ComfyApi
|
||||
*/
|
||||
function createAssetService() {
|
||||
let inputAssetsIncludingPublic: AssetItem[] | null = null
|
||||
let inputAssetsIncludingPublicRequestId = 0
|
||||
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
|
||||
|
||||
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
|
||||
function invalidateInputAssetsIncludingPublic(): void {
|
||||
inputAssetsIncludingPublicRequestId++
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
inputAssetsIncludingPublic = null
|
||||
}
|
||||
|
||||
function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void {
|
||||
if (tags?.includes('input')) invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API response with consistent error handling and Zod validation
|
||||
*/
|
||||
@@ -203,7 +292,8 @@ function createAssetService() {
|
||||
includeTags,
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
includePublic
|
||||
includePublic,
|
||||
signal
|
||||
} = options
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: includeTags.join(','),
|
||||
@@ -217,7 +307,9 @@ function createAssetService() {
|
||||
}
|
||||
|
||||
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
|
||||
const res = await api.fetchApi(url)
|
||||
const res = signal
|
||||
? await api.fetchApi(url, { signal })
|
||||
: await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
@@ -403,15 +495,16 @@ function createAssetService() {
|
||||
* @param options - Pagination options
|
||||
* @param options.limit - Maximum number of assets to return (default: 500)
|
||||
* @param options.offset - Number of assets to skip (default: 0)
|
||||
* @param options.signal - Optional abort signal for cancelling the request
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
|
||||
*/
|
||||
async function getAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [tag], limit, offset, includePublic },
|
||||
{ includeTags: [tag], limit, offset, includePublic, signal },
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
|
||||
@@ -420,6 +513,116 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets every asset for a tag by walking paginated asset API responses.
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models', 'input')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
* @param options - Pagination options
|
||||
* @param options.limit - Page size for each request (default: 500)
|
||||
* @param options.signal - Optional abort signal for cancelling requests
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag
|
||||
*/
|
||||
async function getAllAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const assets: AssetItem[] = []
|
||||
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
{
|
||||
includeTags: [tag],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
includePublic,
|
||||
signal
|
||||
},
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
const batch = data.assets ?? []
|
||||
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
|
||||
|
||||
const noMoreFromServer = data.has_more === false
|
||||
const inferredLastPage =
|
||||
data.has_more === undefined && batch.length < pageSize
|
||||
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
|
||||
return assets
|
||||
}
|
||||
|
||||
offset += batch.length
|
||||
}
|
||||
}
|
||||
|
||||
function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> {
|
||||
const requestId = ++inputAssetsIncludingPublicRequestId
|
||||
|
||||
pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, {
|
||||
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
|
||||
})
|
||||
.then((assets) => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
inputAssetsIncludingPublic = assets
|
||||
}
|
||||
return assets
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
}
|
||||
})
|
||||
|
||||
void pendingInputAssetsIncludingPublic.catch(() => {})
|
||||
return pendingInputAssetsIncludingPublic
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached input assets including public assets for missing media checks.
|
||||
* Caller aborts cancel only that caller; shared fetches are invalidated
|
||||
* through invalidateInputAssetsIncludingPublic().
|
||||
*/
|
||||
async function getInputAssetsIncludingPublic(
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
throwIfAborted(signal)
|
||||
if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic
|
||||
|
||||
const request =
|
||||
pendingInputAssetsIncludingPublic ??
|
||||
startInputAssetsIncludingPublicRequest()
|
||||
return await withCallerAbort(request, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an asset exists for an exact asset hash.
|
||||
*
|
||||
* Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
|
||||
* 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
|
||||
*/
|
||||
async function checkAssetHash(
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetHashStatus> {
|
||||
const response = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 200) return 'exists'
|
||||
if (response.status === 404) return 'missing'
|
||||
if (response.status === 400) return 'invalid'
|
||||
|
||||
throw new Error(`Unexpected asset hash check status: ${response.status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset by ID
|
||||
* Only available in cloud environment
|
||||
@@ -438,6 +641,8 @@ function createAssetService() {
|
||||
`Unable to delete asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,7 +750,9 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
const asset = validateUploadedAssetResponse(await res.json())
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return asset
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,7 +805,9 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
const asset = validateUploadedAssetResponse(await res.json())
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return asset
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,6 +837,7 @@ function createAssetService() {
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
@@ -658,6 +868,7 @@ function createAssetService() {
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
@@ -709,6 +920,13 @@ function createAssetService() {
|
||||
)
|
||||
)
|
||||
}
|
||||
if (
|
||||
params.tags?.includes('input') &&
|
||||
result.data.type === 'async' &&
|
||||
result.data.task.status === 'completed'
|
||||
) {
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
@@ -724,6 +942,7 @@ function createAssetService() {
|
||||
)
|
||||
)
|
||||
}
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return result.data
|
||||
}
|
||||
|
||||
@@ -764,6 +983,10 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
getAllAssetsByTag,
|
||||
getInputAssetsIncludingPublic,
|
||||
invalidateInputAssetsIncludingPublic,
|
||||
checkAssetHash,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addAssetTags,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
scanNodeMediaCandidates,
|
||||
@@ -13,6 +15,13 @@ import {
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
|
||||
() => ({
|
||||
mockCheckAssetHash: vi.fn(),
|
||||
mockGetInputAssetsIncludingPublic: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
getExecutionIdByNode: (
|
||||
@@ -21,6 +30,21 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash,
|
||||
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
@@ -70,6 +94,16 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
|
||||
return fromAny<LGraph, unknown>({ _testNodes: nodes })
|
||||
}
|
||||
|
||||
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
}
|
||||
|
||||
describe('scanNodeMediaCandidates', () => {
|
||||
it('returns candidate for a LoadImage node with missing image', () => {
|
||||
const graph = makeGraph([])
|
||||
@@ -232,37 +266,43 @@ describe('groupCandidatesByMediaType', () => {
|
||||
})
|
||||
|
||||
describe('verifyCloudMediaCandidates', () => {
|
||||
it('marks candidates missing when not in input assets', async () => {
|
||||
const existingHash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const missingHash =
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('marks candidates missing when the asset hash is not found', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'def456.png', { isMissing: undefined })
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', existingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
|
||||
}
|
||||
const checkAssetHash = vi.fn(async (assetHash: string) =>
|
||||
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
|
||||
)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('calls updateInputs before checking assets', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
it('uses assetService.checkAssetHash by default', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(updateCalled).toBe(true)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
})
|
||||
|
||||
it('respects abort signal before execution', async () => {
|
||||
@@ -270,69 +310,221 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
controller.abort()
|
||||
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined })
|
||||
makeCandidate('1', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects abort signal after updateInputs', async () => {
|
||||
it('respects abort signal after hash verification', async () => {
|
||||
const controller = new AbortController()
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
controller.abort()
|
||||
return 'exists' as const
|
||||
})
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
controller.abort()
|
||||
},
|
||||
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
checkAssetHash
|
||||
)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as true', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as false', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
|
||||
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips entirely when no pending candidates', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(updateCalled).toBe(false)
|
||||
it('falls back to input assets for non-blake3 candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('stored-photo.png', 'photo.png')
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('uses public input assets for default legacy fallback', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
|
||||
]
|
||||
const inputAssets = Array.from({ length: 500 }, (_, index) =>
|
||||
makeAsset(`asset-${index}.png`)
|
||||
)
|
||||
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('silences aborts while loading legacy fallback input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('silences aborts from the default legacy fallback input asset store path', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
controller.signal
|
||||
)
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to input assets when the hash endpoint returns 400', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('invalid')
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to input assets when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
throw new Error('network failed')
|
||||
})
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
checkAssetHash,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
|
||||
const malformedHash = 'blake3:abc'
|
||||
const candidates = [
|
||||
makeCandidate('1', malformedHash, { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('legacy.png', malformedHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates checks for repeated candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
isBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
@@ -106,41 +112,130 @@ export function scanNodeMediaCandidates(
|
||||
return candidates
|
||||
}
|
||||
|
||||
interface InputVerifier {
|
||||
updateInputs: () => Promise<unknown>
|
||||
inputAssets: Array<{ asset_hash?: string | null; name: string }>
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
|
||||
|
||||
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>
|
||||
legacyCandidates: MissingMediaCandidate[]
|
||||
} {
|
||||
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
|
||||
const legacyCandidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isBlake3AssetHash(candidate.name)) {
|
||||
legacyCandidates.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(candidate.name)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(candidate.name, [candidate])
|
||||
}
|
||||
|
||||
return { candidatesByHash, legacyCandidates }
|
||||
}
|
||||
|
||||
async function verifyCandidatesByHash(
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>,
|
||||
legacyCandidates: MissingMediaCandidate[],
|
||||
signal: AbortSignal | undefined,
|
||||
checkAssetHash: AssetHashVerifier
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = status === 'missing'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates against the input assets fetched from the
|
||||
* assets store. Mutates candidates' `isMissing` in place.
|
||||
* Verify cloud media candidates by probing the asset hash endpoint first.
|
||||
* Invalid hash values fall back to the legacy input asset list check.
|
||||
*/
|
||||
export async function verifyCloudMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: InputVerifier
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
|
||||
fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
const { candidatesByHash, legacyCandidates } =
|
||||
groupCandidatesForHashLookup(pending)
|
||||
await verifyCandidatesByHash(
|
||||
candidatesByHash,
|
||||
legacyCandidates,
|
||||
signal,
|
||||
checkAssetHash
|
||||
)
|
||||
|
||||
await store.updateInputs()
|
||||
if (signal?.aborted || legacyCandidates.length === 0) return
|
||||
|
||||
let inputAssets: AssetItem[]
|
||||
try {
|
||||
inputAssets = await fetchInputAssets(signal)
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
throw err
|
||||
}
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
const assetHashes = new Set(
|
||||
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const c of pending) {
|
||||
c.isMissing = !assetHashes.has(c.name)
|
||||
for (const candidate of legacyCandidates) {
|
||||
candidate.isMissing = !assetHashes.has(candidate.name)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingInputAssets(
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
return await assetService.getInputAssetsIncludingPublic(signal)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
|
||||
@@ -19,6 +19,11 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
|
||||
const { mockCheckAssetHash } = vi.hoisted(() => ({
|
||||
mockCheckAssetHash: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
@@ -28,6 +33,20 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Helper: create a combo widget mock */
|
||||
function makeComboWidget(
|
||||
name: string,
|
||||
@@ -43,7 +62,7 @@ function makeComboWidget(
|
||||
}
|
||||
|
||||
/** Helper: create an asset widget mock (Cloud combo replacement) */
|
||||
function makeAssetWidget(name: string, value: string): IBaseWidget {
|
||||
function makeAssetWidget(name: string, value: unknown): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({
|
||||
type: 'asset',
|
||||
name,
|
||||
@@ -551,6 +570,16 @@ describe('scanAllModelCandidates', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should skip asset widgets with non-string values', () => {
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'SomeNode', [makeAssetWidget('ckpt_name', 123)])
|
||||
])
|
||||
|
||||
const result = scanAllModelCandidates(graph, noAssetSupport)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should scan both combo and asset widgets on the same node', () => {
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'DualLoaderNode', [
|
||||
@@ -1411,6 +1440,7 @@ function makeAssetCandidate(
|
||||
describe('verifyAssetSupportedCandidates', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockIsModelLoading.mockReturnValue(false)
|
||||
mockHasMore.mockReturnValue(false)
|
||||
mockGetAssets.mockReturnValue([])
|
||||
@@ -1428,6 +1458,125 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
|
||||
const hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when the blake3 hash is not found', async () => {
|
||||
const hash =
|
||||
'2222222222222222222222222222222222222222222222222222222222222222'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hash =
|
||||
'3333333333333333333333333333333333333333333333333333333333333333'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should skip malformed blake3 hashes and use asset store matching', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash: 'abc123',
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('should not warn or fall back when hash verification is aborted', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const hash =
|
||||
'4444444444444444444444444444444444444444444444444444444444444444'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(abortError)
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
@@ -1442,6 +1591,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching filename exists', async () => {
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -177,7 +182,7 @@ function scanAssetWidget(
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (!value.trim()) return null
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
return {
|
||||
@@ -445,20 +450,68 @@ interface AssetVerifier {
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: AssetVerifier
|
||||
assetsStore?: AssetVerifier,
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pendingCandidates = candidates.filter(
|
||||
(c) => c.isAssetSupported && c.isMissing === undefined
|
||||
)
|
||||
if (pendingCandidates.length === 0) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
for (const c of candidates) {
|
||||
if (c.isAssetSupported && c.isMissing === undefined) {
|
||||
pendingNodeTypes.add(c.nodeType)
|
||||
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
const assetHash = getBlake3AssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
try {
|
||||
const status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
|
||||
if (status === 'exists') {
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
if (pendingNodeTypes.size === 0) return
|
||||
|
||||
const store =
|
||||
@@ -491,6 +544,20 @@ export async function verifyAssetSupportedCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
|
||||
if (candidate.hashType?.toLowerCase() !== 'blake3') return null
|
||||
return toBlake3AssetHash(candidate.hash)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -36,12 +36,9 @@ function createMockLGraphCanvas(read_only = true): LGraphCanvas {
|
||||
}
|
||||
|
||||
function createMockPointerEvent(
|
||||
buttons: PointerEvent['buttons'] = 1,
|
||||
{ type = 'pointerdown', button = 0 }: { type?: string; button?: number } = {}
|
||||
buttons: PointerEvent['buttons'] = 1
|
||||
): PointerEvent {
|
||||
const mockEvent: Partial<PointerEvent> = {
|
||||
type,
|
||||
button,
|
||||
buttons,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
@@ -79,30 +76,13 @@ describe('useCanvasInteractions', () => {
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward middle-button pointerdown to canvas', () => {
|
||||
it('should forward middle mouse button events to canvas', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockPointerEvent(4, {
|
||||
type: 'pointerdown',
|
||||
button: 1
|
||||
})
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward middle-held pointermove to canvas even when chorded with left', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
// buttons=5 = middle + left held simultaneously.
|
||||
const mockEvent = createMockPointerEvent(5, { type: 'pointermove' })
|
||||
const mockEvent = createMockPointerEvent(4) // Middle mouse button
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -72,11 +72,7 @@ export function useCanvasInteractions() {
|
||||
* be forwarded to canvas (e.g., space+drag for panning)
|
||||
*/
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
// Route through the shared type-dispatcher so pointerdown uses strict
|
||||
// semantics (chorded left-click with middle held is NOT middle input),
|
||||
// pointermove uses the bitmask held check to survive chords, and
|
||||
// pointerup identifies the released button via `button`.
|
||||
if (isMiddleForPointerEvent(event)) {
|
||||
if (isMiddlePointerInput(event)) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
@@ -85,11 +81,15 @@ export function useCanvasInteractions() {
|
||||
const canvas = getCanvas()
|
||||
if (!canvas) return
|
||||
|
||||
// Space+left-drag panning (read_only is set while space is held)
|
||||
if (canvas.read_only && event.buttons === 1) {
|
||||
// Check conditions for forwarding events to canvas
|
||||
const isSpacePanningDrag = canvas.read_only && event.buttons === 1 // Space key pressed + left mouse drag
|
||||
const isMiddleMousePanning = event.buttons === 4 // Middle mouse button for panning
|
||||
|
||||
if (isSpacePanningDrag || isMiddleMousePanning) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,25 +100,6 @@ describe('useTransformSettling', () => {
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat middle-click as pan', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 1 })
|
||||
)
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pointermove without pointerdown', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
@@ -52,21 +50,13 @@ export function useTransformSettling(
|
||||
|
||||
const isTransforming = ref(false)
|
||||
|
||||
// useTimeoutFn auto-stops the pending timer on scope dispose via VueUse's
|
||||
// tryOnScopeDispose, so the settle timer doesn't outlive an unmounting
|
||||
// component and resurrect `isTransforming` into a disposed scope.
|
||||
const { start: restartSettleTimer } = useTimeoutFn(
|
||||
() => {
|
||||
isTransforming.value = false
|
||||
},
|
||||
settleDelay,
|
||||
{ immediate: false }
|
||||
)
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
function markInteracting() {
|
||||
isTransforming.value = true
|
||||
// Each call resets the timer — start() stops any pending one first.
|
||||
restartSettleTimer()
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
const eventOptions = { capture: true, passive }
|
||||
@@ -94,7 +84,8 @@ function usePointerDrag(
|
||||
target,
|
||||
'pointerdown',
|
||||
(e: PointerEvent) => {
|
||||
if (e.button === 0 || isMiddlePointerInput(e)) pointerCount.value++
|
||||
// Only primary (0) and middle (1) buttons trigger canvas pan.
|
||||
if (e.button === 0 || e.button === 1) pointerCount.value++
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onScopeDispose, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { useClickDragGuard } from '@/composables/useClickDragGuard'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
@@ -22,7 +22,7 @@ export function useNodePointerInteractions(
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const forwardMiddlePointerIfNeeded = (event: PointerEvent) => {
|
||||
if (!isMiddleForPointerEvent(event)) return false
|
||||
if (!isMiddlePointerInput(event)) return false
|
||||
forwardEventToCanvas(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,6 +3,18 @@ import { vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface FakeDOMWidget {
|
||||
name: string
|
||||
type: string
|
||||
element: HTMLElement
|
||||
options: Record<string, unknown>
|
||||
value: string
|
||||
callback?: (value: string) => void
|
||||
onRemove?: () => void
|
||||
serialize?: boolean
|
||||
serializeValue?: () => unknown
|
||||
}
|
||||
|
||||
interface FakeMediaWidget {
|
||||
name: string
|
||||
element: HTMLElement
|
||||
@@ -14,6 +26,26 @@ interface FakeMediaWidget {
|
||||
|
||||
type NodeOverrides = Record<string, unknown> & { widgets?: never }
|
||||
|
||||
export function createMockDOMWidgetNode(overrides: NodeOverrides = {}) {
|
||||
const widgets: FakeDOMWidget[] = []
|
||||
return fromAny<LGraphNode & { widgets: FakeDOMWidget[] }, unknown>({
|
||||
id: 1,
|
||||
widgets,
|
||||
addDOMWidget: vi.fn((name: string, type: string, element: HTMLElement) => {
|
||||
const widget: FakeDOMWidget = {
|
||||
name,
|
||||
type,
|
||||
element,
|
||||
options: {},
|
||||
value: ''
|
||||
}
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockMediaNode(overrides: NodeOverrides = {}) {
|
||||
const widgets: FakeMediaWidget[] = []
|
||||
return fromAny<LGraphNode & { widgets: FakeMediaWidget[] }, unknown>({
|
||||
|
||||
@@ -1,276 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import type * as LitegraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import type * as Litegraph from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
|
||||
import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
type TestWidget = {
|
||||
element: HTMLElement
|
||||
options: {
|
||||
getValue?: () => string
|
||||
setValue?: (value: string) => void
|
||||
minNodeSize?: [number, number]
|
||||
const { canvasMock } = vi.hoisted(() => ({
|
||||
canvasMock: {
|
||||
processMouseDown: vi.fn(),
|
||||
processMouseMove: vi.fn(),
|
||||
processMouseUp: vi.fn()
|
||||
}
|
||||
value: string
|
||||
callback: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const processMouseDown = vi.fn()
|
||||
const processMouseMove = vi.fn()
|
||||
const processMouseUp = vi.fn()
|
||||
let widgetState: { value: unknown } | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
processMouseDown: (e: Event) => processMouseDown(e),
|
||||
processMouseMove: (e: Event) => processMouseMove(e),
|
||||
processMouseUp: (e: Event) => processMouseUp(e)
|
||||
},
|
||||
rootGraph: { id: 'root' }
|
||||
}
|
||||
app: { rootGraph: { id: 'root' }, canvas: canvasMock }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: () => widgetState
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LitegraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveNodeRootGraphId: () => 'root'
|
||||
}
|
||||
const actual = await importOriginal<typeof Litegraph>()
|
||||
return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') }
|
||||
})
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => undefined })
|
||||
}))
|
||||
|
||||
function resetMocks() {
|
||||
vi.clearAllMocks()
|
||||
widgetState = undefined
|
||||
}
|
||||
|
||||
function createNodeMock(): {
|
||||
node: LGraphNode
|
||||
getInputEl: () => HTMLElement
|
||||
getTextarea: () => HTMLTextAreaElement
|
||||
getWidget: () => TestWidget
|
||||
} {
|
||||
let capturedEl: HTMLElement | undefined
|
||||
let capturedWidget: TestWidget | undefined
|
||||
|
||||
const node = {
|
||||
id: 1,
|
||||
addDOMWidget: vi.fn(
|
||||
(
|
||||
_name: string,
|
||||
_type: string,
|
||||
el: HTMLElement,
|
||||
options: TestWidget['options']
|
||||
) => {
|
||||
capturedEl = el
|
||||
capturedWidget = {
|
||||
element: el,
|
||||
options,
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
return capturedWidget
|
||||
}
|
||||
)
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return {
|
||||
node,
|
||||
getInputEl: () => {
|
||||
if (!capturedEl) throw new Error('addDOMWidget was not invoked')
|
||||
return capturedEl
|
||||
},
|
||||
getTextarea: () => {
|
||||
const textarea = capturedEl?.querySelector('textarea')
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Markdown textarea was not created')
|
||||
}
|
||||
return textarea
|
||||
},
|
||||
getWidget: () => {
|
||||
if (!capturedWidget) throw new Error('addDOMWidget was not invoked')
|
||||
return capturedWidget
|
||||
}
|
||||
function createMarkdownWidget(node: LGraphNode) {
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'MARKDOWN',
|
||||
name: 'note',
|
||||
default: ''
|
||||
}
|
||||
return useMarkdownWidget()(node, inputSpec) as DOMWidget<HTMLElement, string>
|
||||
}
|
||||
|
||||
const markdownInputSpec: InputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'text',
|
||||
default: ''
|
||||
} as InputSpec
|
||||
|
||||
describe('useMarkdownWidget', () => {
|
||||
beforeEach(resetMocks)
|
||||
function setup() {
|
||||
vi.clearAllMocks()
|
||||
const node = createMockDOMWidgetNode()
|
||||
const widget = createMarkdownWidget(node)
|
||||
const callback = vi.fn<(value: string) => void>()
|
||||
widget.callback = callback
|
||||
const inputEl = widget.element
|
||||
const textarea = inputEl.querySelector('textarea')!
|
||||
const parentKeydown = vi.fn<(ev: KeyboardEvent) => void>()
|
||||
document.body.append(inputEl)
|
||||
document.body.addEventListener('keydown', parentKeydown)
|
||||
onTestFinished(() => {
|
||||
document.body.removeEventListener('keydown', parentKeydown)
|
||||
inputEl.remove()
|
||||
})
|
||||
return { widget, inputEl, textarea, callback, parentKeydown }
|
||||
}
|
||||
|
||||
it('syncs DOM widget value with widget state when available', () => {
|
||||
widgetState = { value: 'stored' }
|
||||
const { node, getTextarea, getWidget } = createNodeMock()
|
||||
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const textarea = getTextarea()
|
||||
const widget = getWidget()
|
||||
|
||||
expect(widget.options.getValue?.()).toBe('stored')
|
||||
|
||||
widget.options.setValue?.('updated')
|
||||
|
||||
expect(textarea.value).toBe('updated')
|
||||
expect(widgetState.value).toBe('updated')
|
||||
it('fires the widget callback on textarea input and change', () => {
|
||||
const { textarea, callback } = setup()
|
||||
textarea.value = 'hello'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
expect(callback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to textarea value when no widget state exists', () => {
|
||||
const { node, getTextarea, getWidget } = createNodeMock()
|
||||
it('toggles editing on dblclick/blur and stops keydown propagation', () => {
|
||||
const { inputEl, textarea, parentKeydown } = setup()
|
||||
inputEl.dispatchEvent(new Event('dblclick', { bubbles: true }))
|
||||
expect(inputEl.classList.contains('editing')).toBe(true)
|
||||
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const textarea = getTextarea()
|
||||
const widget = getWidget()
|
||||
textarea.value = 'typed'
|
||||
textarea.dispatchEvent(new Event('blur'))
|
||||
expect(inputEl.classList.contains('editing')).toBe(false)
|
||||
|
||||
expect(widget.options.getValue?.()).toBe('typed')
|
||||
inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }))
|
||||
expect(parentKeydown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates widget value and invokes callback on markdown input', () => {
|
||||
const { node, getTextarea, getWidget } = createNodeMock()
|
||||
it('forwards middle-click pointer events to the canvas while alive', () => {
|
||||
const { inputEl } = setup()
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const textarea = getTextarea()
|
||||
const widget = getWidget()
|
||||
textarea.value = 'typed'
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, inputType: 'insertText' })
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('typed')
|
||||
expect(widget.callback).toHaveBeenCalledWith('typed')
|
||||
expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1)
|
||||
expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1)
|
||||
expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('toggles editing state around double-click and blur', () => {
|
||||
vi.useFakeTimers()
|
||||
const { node, getInputEl, getTextarea } = createNodeMock()
|
||||
it('detaches every listener and lets keydown bubble after removal', () => {
|
||||
const { widget, inputEl, textarea, callback, parentKeydown } = setup()
|
||||
widget.onRemove?.()
|
||||
|
||||
try {
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const inputEl = getInputEl()
|
||||
const textarea = getTextarea()
|
||||
const focusSpy = vi.spyOn(textarea, 'focus')
|
||||
textarea.value = 'after'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
inputEl.dispatchEvent(new Event('dblclick', { bubbles: true }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }))
|
||||
|
||||
inputEl.dispatchEvent(new MouseEvent('dblclick'))
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(inputEl.classList.contains('editing')).toBe(true)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
textarea.dispatchEvent(new FocusEvent('blur'))
|
||||
|
||||
expect(inputEl.classList.contains('editing')).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseDown).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseMove).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseUp).not.toHaveBeenCalled()
|
||||
expect(inputEl.classList.contains('editing')).toBe(false)
|
||||
// keydown listener (which called stopPropagation) is gone, so the event
|
||||
// now bubbles to the parent.
|
||||
expect(parentKeydown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates rendered content and invokes callback on textarea change', () => {
|
||||
const { node, getTextarea, getWidget } = createNodeMock()
|
||||
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const textarea = getTextarea()
|
||||
const widget = getWidget()
|
||||
textarea.value = '# heading'
|
||||
textarea.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(widget.callback).toHaveBeenCalledWith(widget.value)
|
||||
})
|
||||
|
||||
it('stops keydown events inside the markdown widget', () => {
|
||||
const { node, getInputEl } = createNodeMock()
|
||||
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
const inputEl = getInputEl()
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true })
|
||||
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
|
||||
inputEl.dispatchEvent(event)
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMarkdownWidget pointer handlers', () => {
|
||||
let inputEl: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
const { node, getInputEl } = createNodeMock()
|
||||
useMarkdownWidget()(node, markdownInputSpec)
|
||||
inputEl = getInputEl()
|
||||
})
|
||||
|
||||
describe('pointerdown', () => {
|
||||
it('forwards middle-button pointerdown to canvas', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
expect(processMouseDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores left-button pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores right-button pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores left-click pointerdown when middle is incidentally held', () => {
|
||||
inputEl.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
|
||||
)
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointermove', () => {
|
||||
it('forwards pointermove while middle is the only held button', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards pointermove when middle is held chorded with left', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards pointermove when middle is held chorded with right', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores pointermove when middle is not held', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 }))
|
||||
expect(processMouseMove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointerup', () => {
|
||||
it('forwards middle-button pointerup to canvas', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
expect(processMouseUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores left-button pointerup', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 }))
|
||||
expect(processMouseUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores right-button pointerup', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 2 }))
|
||||
expect(processMouseUp).not.toHaveBeenCalled()
|
||||
})
|
||||
it('survives onRemove being invoked twice', () => {
|
||||
const { widget } = setup()
|
||||
widget.onRemove?.()
|
||||
expect(() => widget.onRemove?.()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,9 @@ import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
@@ -65,34 +65,74 @@ function addMarkdownWidget(
|
||||
widget.element = inputEl
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', (event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
widget.value = event.target.value
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
|
||||
inputEl.addEventListener(
|
||||
'input',
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
widget.value = event.target.value
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
inputEl.addEventListener(
|
||||
'dblclick',
|
||||
() => {
|
||||
inputEl.classList.add('editing')
|
||||
setTimeout(() => textarea.focus(), 0)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
textarea.addEventListener('blur', () => inputEl.classList.remove('editing'), {
|
||||
signal
|
||||
})
|
||||
|
||||
inputEl.addEventListener('dblclick', () => {
|
||||
inputEl.classList.add('editing')
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
textarea.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
editor.commands.setContent(textarea.value)
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
inputEl.addEventListener('keydown', (event) => event.stopPropagation(), {
|
||||
signal
|
||||
})
|
||||
|
||||
textarea.addEventListener('blur', () => {
|
||||
inputEl.classList.remove('editing')
|
||||
})
|
||||
inputEl.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (event.button === 1) app.canvas.processMouseDown(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
textarea.addEventListener('change', () => {
|
||||
editor.commands.setContent(textarea.value)
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
inputEl.addEventListener(
|
||||
'pointermove',
|
||||
(event) => {
|
||||
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
inputEl.addEventListener(
|
||||
'pointerup',
|
||||
(event) => {
|
||||
if (event.button === 1) app.canvas.processMouseUp(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
forwardMiddleButtonToCanvas(inputEl)
|
||||
widget.onRemove = useChainCallback(widget.onRemove, () => {
|
||||
controller.abort()
|
||||
if (!editor.isDestroyed) editor.destroy()
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
@@ -1,380 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import type * as LitegraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import type * as FeedbackModule from '@/lib/litegraph/src/utils/feedback'
|
||||
import type * as Litegraph from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
|
||||
import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
type TestWidget = {
|
||||
element: HTMLTextAreaElement
|
||||
options: {
|
||||
getValue?: () => string
|
||||
setValue?: (value: string) => void
|
||||
minNodeSize?: [number, number]
|
||||
const { canvasMock } = vi.hoisted(() => ({
|
||||
canvasMock: {
|
||||
processMouseDown: vi.fn(),
|
||||
processMouseMove: vi.fn(),
|
||||
processMouseUp: vi.fn(),
|
||||
processMouseWheel: vi.fn()
|
||||
}
|
||||
value: string
|
||||
callback: ReturnType<typeof vi.fn>
|
||||
dynamicPrompts?: boolean
|
||||
}
|
||||
|
||||
const processMouseDown = vi.fn()
|
||||
const processMouseMove = vi.fn()
|
||||
const processMouseUp = vi.fn()
|
||||
const processMouseWheel = vi.fn()
|
||||
const settings = new Map<string, boolean>()
|
||||
let widgetState: { value: unknown } | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
processMouseDown: (e: Event) => processMouseDown(e),
|
||||
processMouseMove: (e: Event) => processMouseMove(e),
|
||||
processMouseUp: (e: Event) => processMouseUp(e),
|
||||
processMouseWheel: (e: Event) => processMouseWheel(e)
|
||||
},
|
||||
rootGraph: { id: 'root' }
|
||||
}
|
||||
app: { rootGraph: { id: 'root' }, canvas: canvasMock }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings.get(key)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: () => widgetState
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LitegraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveNodeRootGraphId: () => 'root'
|
||||
const actual = await importOriginal<typeof Litegraph>()
|
||||
return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') }
|
||||
})
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => undefined })
|
||||
}))
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => false })
|
||||
}))
|
||||
|
||||
function createStringWidget(node: LGraphNode) {
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'prompt',
|
||||
default: '',
|
||||
multiline: true
|
||||
}
|
||||
})
|
||||
return useStringWidget()(node, inputSpec) as DOMWidget<
|
||||
HTMLTextAreaElement,
|
||||
string
|
||||
>
|
||||
}
|
||||
|
||||
vi.mock('@/lib/litegraph/src/utils/feedback', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FeedbackModule>()
|
||||
return {
|
||||
...actual,
|
||||
defineDeprecatedProperty: vi.fn()
|
||||
describe('useStringWidget (multiline)', () => {
|
||||
function setup() {
|
||||
vi.clearAllMocks()
|
||||
const node = createMockDOMWidgetNode()
|
||||
const widget = createStringWidget(node)
|
||||
const callback = vi.fn<(value: string) => void>()
|
||||
widget.callback = callback
|
||||
const inputEl = widget.element
|
||||
document.body.append(inputEl)
|
||||
onTestFinished(() => inputEl.remove())
|
||||
return { widget, inputEl, callback }
|
||||
}
|
||||
})
|
||||
|
||||
function resetMocks() {
|
||||
vi.clearAllMocks()
|
||||
settings.clear()
|
||||
settings.set('Comfy.TextareaWidget.Spellcheck', false)
|
||||
settings.set('LiteGraph.Pointer.TrackpadGestures', false)
|
||||
widgetState = undefined
|
||||
}
|
||||
|
||||
function createNodeMock(): {
|
||||
node: LGraphNode
|
||||
getInputEl: () => HTMLTextAreaElement
|
||||
getWidget: () => TestWidget
|
||||
addWidget: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
let capturedEl: HTMLTextAreaElement | undefined
|
||||
let capturedWidget: TestWidget | undefined
|
||||
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
_type: string,
|
||||
_name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
_options: object
|
||||
) => ({
|
||||
value,
|
||||
options: {}
|
||||
})
|
||||
)
|
||||
|
||||
const node = {
|
||||
id: 1,
|
||||
addWidget,
|
||||
addDOMWidget: vi.fn(
|
||||
(
|
||||
_name: string,
|
||||
_type: string,
|
||||
el: HTMLTextAreaElement,
|
||||
options: TestWidget['options']
|
||||
) => {
|
||||
capturedEl = el
|
||||
capturedWidget = {
|
||||
element: el,
|
||||
options,
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
return capturedWidget
|
||||
}
|
||||
)
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return {
|
||||
node,
|
||||
getInputEl: () => {
|
||||
if (!capturedEl) throw new Error('addDOMWidget was not invoked')
|
||||
return capturedEl
|
||||
},
|
||||
getWidget: () => {
|
||||
if (!capturedWidget) throw new Error('addDOMWidget was not invoked')
|
||||
return capturedWidget
|
||||
},
|
||||
addWidget
|
||||
}
|
||||
}
|
||||
|
||||
function setScrollMetrics(
|
||||
inputEl: HTMLTextAreaElement,
|
||||
metrics: { scrollHeight: number; clientHeight: number }
|
||||
) {
|
||||
Object.defineProperties(inputEl, {
|
||||
scrollHeight: { configurable: true, value: metrics.scrollHeight },
|
||||
clientHeight: { configurable: true, value: metrics.clientHeight }
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchWheel(
|
||||
inputEl: HTMLTextAreaElement,
|
||||
init: WheelEventInit
|
||||
): WheelEvent {
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init
|
||||
})
|
||||
inputEl.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function expectWheelForwarded(event: WheelEvent) {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(processMouseWheel).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
|
||||
const multilineInputSpec: InputSpec = {
|
||||
type: 'STRING',
|
||||
name: 'text',
|
||||
multiline: true,
|
||||
default: ''
|
||||
} as InputSpec
|
||||
|
||||
describe('useStringWidget', () => {
|
||||
beforeEach(resetMocks)
|
||||
|
||||
it('creates a single-line text widget for non-multiline inputs', () => {
|
||||
const { node, addWidget } = createNodeMock()
|
||||
|
||||
const widget = useStringWidget()(node, {
|
||||
type: 'STRING',
|
||||
name: 'text',
|
||||
default: 'hello'
|
||||
} as InputSpec)
|
||||
|
||||
expect(addWidget).toHaveBeenCalledWith(
|
||||
'text',
|
||||
'text',
|
||||
'hello',
|
||||
expect.any(Function),
|
||||
{}
|
||||
)
|
||||
expect(widget.value).toBe('hello')
|
||||
it('fires the widget callback on input', () => {
|
||||
const { inputEl, callback } = setup()
|
||||
inputEl.value = 'hello'
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('copies dynamic prompt metadata when present', () => {
|
||||
const { node } = createNodeMock()
|
||||
it('forwards middle-click pointer events and ctrl+wheel to the canvas while alive', () => {
|
||||
const { inputEl } = setup()
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true }))
|
||||
|
||||
const widget = useStringWidget()(node, {
|
||||
type: 'STRING',
|
||||
name: 'text',
|
||||
default: 'hello',
|
||||
dynamicPrompts: true
|
||||
} as InputSpec)
|
||||
|
||||
expect(widget.dynamicPrompts).toBe(true)
|
||||
expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1)
|
||||
expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1)
|
||||
expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1)
|
||||
expect(canvasMock.processMouseWheel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws for non-string input specs', () => {
|
||||
const { node } = createNodeMock()
|
||||
it('detaches every listener when the widget is removed', () => {
|
||||
const { widget, inputEl, callback } = setup()
|
||||
widget.onRemove?.()
|
||||
|
||||
expect(() =>
|
||||
useStringWidget()(node, {
|
||||
type: 'INT',
|
||||
name: 'text'
|
||||
} as InputSpec)
|
||||
).toThrow('Invalid input data')
|
||||
})
|
||||
inputEl.value = 'after'
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true }))
|
||||
|
||||
it('syncs multiline DOM widget value with widget state when available', () => {
|
||||
widgetState = { value: 'stored' }
|
||||
const { node, getInputEl, getWidget } = createNodeMock()
|
||||
|
||||
useStringWidget()(node, multilineInputSpec)
|
||||
const inputEl = getInputEl()
|
||||
const widget = getWidget()
|
||||
|
||||
expect(widget.options.getValue?.()).toBe('stored')
|
||||
|
||||
widget.options.setValue?.('updated')
|
||||
|
||||
expect(inputEl.value).toBe('updated')
|
||||
expect(widgetState.value).toBe('updated')
|
||||
})
|
||||
|
||||
it('falls back to textarea value when no widget state exists', () => {
|
||||
const { node, getInputEl, getWidget } = createNodeMock()
|
||||
|
||||
useStringWidget()(node, multilineInputSpec)
|
||||
const inputEl = getInputEl()
|
||||
const widget = getWidget()
|
||||
inputEl.value = 'typed'
|
||||
|
||||
expect(widget.options.getValue?.()).toBe('typed')
|
||||
})
|
||||
|
||||
it('updates widget value and invokes callback on textarea input', () => {
|
||||
const { node, getInputEl, getWidget } = createNodeMock()
|
||||
|
||||
useStringWidget()(node, multilineInputSpec)
|
||||
const inputEl = getInputEl()
|
||||
const widget = getWidget()
|
||||
inputEl.value = 'typed'
|
||||
inputEl.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
||||
|
||||
expect(widget.value).toBe('typed')
|
||||
expect(widget.callback).toHaveBeenCalledWith('typed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStringWidget wheel handling', () => {
|
||||
let inputEl: HTMLTextAreaElement
|
||||
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
const { node, getInputEl } = createNodeMock()
|
||||
useStringWidget()(node, multilineInputSpec)
|
||||
inputEl = getInputEl()
|
||||
setScrollMetrics(inputEl, { scrollHeight: 100, clientHeight: 100 })
|
||||
})
|
||||
|
||||
it('forwards ctrl-wheel pinch gestures to the canvas', () => {
|
||||
const event = dispatchWheel(inputEl, { ctrlKey: true, deltaY: 10 })
|
||||
|
||||
expectWheelForwarded(event)
|
||||
})
|
||||
|
||||
it('forwards likely trackpad gestures when trackpad gestures are enabled', () => {
|
||||
settings.set('LiteGraph.Pointer.TrackpadGestures', true)
|
||||
|
||||
const event = dispatchWheel(inputEl, { deltaY: 10 })
|
||||
|
||||
expectWheelForwarded(event)
|
||||
})
|
||||
|
||||
it('forwards horizontal wheel gestures to the canvas', () => {
|
||||
const event = dispatchWheel(inputEl, { deltaX: 120, deltaY: 10 })
|
||||
|
||||
expectWheelForwarded(event)
|
||||
})
|
||||
|
||||
it('keeps vertical wheel events inside a scrollable textarea', () => {
|
||||
setScrollMetrics(inputEl, { scrollHeight: 200, clientHeight: 100 })
|
||||
|
||||
const event = dispatchWheel(inputEl, { deltaY: 120 })
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(processMouseWheel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards vertical wheel events when the textarea cannot scroll', () => {
|
||||
const event = dispatchWheel(inputEl, { deltaY: 120 })
|
||||
|
||||
expectWheelForwarded(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStringWidget multiline pointer handlers', () => {
|
||||
let inputEl: HTMLTextAreaElement
|
||||
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
const { node, getInputEl } = createNodeMock()
|
||||
useStringWidget()(node, multilineInputSpec)
|
||||
inputEl = getInputEl()
|
||||
})
|
||||
|
||||
describe('pointerdown', () => {
|
||||
it('forwards middle-button pointerdown to canvas', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
|
||||
expect(processMouseDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores left-button pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores right-button pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores left-click pointerdown even when middle is incidentally held', () => {
|
||||
inputEl.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
|
||||
)
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointermove', () => {
|
||||
it('forwards pointermove while middle is the only held button', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards pointermove when middle is held chorded with left', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards pointermove when middle is held chorded with right', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores pointermove when middle is not held', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 }))
|
||||
expect(processMouseMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores pointermove with no buttons held', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 0 }))
|
||||
expect(processMouseMove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointerup', () => {
|
||||
it('forwards middle-button pointerup to canvas', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
|
||||
expect(processMouseUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores left-button pointerup', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 }))
|
||||
expect(processMouseUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores right-button pointerup', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 2 }))
|
||||
expect(processMouseUp).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseDown).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseMove).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseUp).not.toHaveBeenCalled()
|
||||
expect(canvasMock.processMouseWheel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -19,13 +19,12 @@ function addMultilineWidget(
|
||||
opts: { defaultVal: string; placeholder?: string }
|
||||
) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.dataset.testid = 'dom-widget-textarea'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
inputEl.spellcheck = settingStore.get('Comfy.TextareaWidget.Spellcheck')
|
||||
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
|
||||
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
@@ -53,64 +52,102 @@ function addMultilineWidget(
|
||||
)
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', (event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
widget.value = event.target.value
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
|
||||
forwardMiddleButtonToCanvas(inputEl)
|
||||
inputEl.addEventListener(
|
||||
'input',
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
widget.value = event.target.value
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
inputEl.addEventListener('wheel', (event: WheelEvent) => {
|
||||
const gesturesEnabled = settingStore.get(
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
// Allow middle mouse button panning
|
||||
inputEl.addEventListener(
|
||||
'pointerdown',
|
||||
(event: PointerEvent) => {
|
||||
if (event.button === 1) app.canvas.processMouseDown(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
inputEl.addEventListener(
|
||||
'pointermove',
|
||||
(event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
// Prevent pinch zoom from zooming the page
|
||||
if (event.ctrlKey) {
|
||||
inputEl.addEventListener(
|
||||
'pointerup',
|
||||
(event: PointerEvent) => {
|
||||
if (event.button === 1) app.canvas.processMouseUp(event)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
inputEl.addEventListener(
|
||||
'wheel',
|
||||
(event: WheelEvent) => {
|
||||
const gesturesEnabled = useSettingStore().get(
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
|
||||
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// Prevent pinch zoom from zooming the page
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if this is likely a trackpad gesture vs mouse wheel
|
||||
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
|
||||
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
|
||||
const isLikelyTrackpad =
|
||||
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
|
||||
|
||||
// Trackpad gestures: when enabled, trackpad panning goes to canvas
|
||||
if (gesturesEnabled && isLikelyTrackpad) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
|
||||
if (canScrollY) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// If textarea can't scroll vertically, pass to canvas
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
// Detect if this is likely a trackpad gesture vs mouse wheel
|
||||
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
|
||||
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
|
||||
const isLikelyTrackpad =
|
||||
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
|
||||
|
||||
// Trackpad gestures: when enabled, trackpad panning goes to canvas
|
||||
if (gesturesEnabled && isLikelyTrackpad) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
|
||||
if (canScrollY) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// If textarea can't scroll vertically, pass to canvas
|
||||
event.preventDefault()
|
||||
app.canvas.processMouseWheel(event)
|
||||
widget.onRemove = useChainCallback(widget.onRemove, () => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
return widget
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
|
||||
|
||||
const processMouseDown = vi.fn()
|
||||
const processMouseMove = vi.fn()
|
||||
const processMouseUp = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
processMouseDown: (e: Event) => processMouseDown(e),
|
||||
processMouseMove: (e: Event) => processMouseMove(e),
|
||||
processMouseUp: (e: Event) => processMouseUp(e)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('forwardMiddleButtonToCanvas', () => {
|
||||
let inputEl: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
inputEl = document.createElement('div')
|
||||
forwardMiddleButtonToCanvas(inputEl)
|
||||
})
|
||||
|
||||
describe('pointerdown — strict semantic', () => {
|
||||
it('forwards a middle-button pointerdown', () => {
|
||||
inputEl.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { button: 1, buttons: 4 })
|
||||
)
|
||||
expect(processMouseDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT forward a chorded pointerdown (left pressed while middle held)', () => {
|
||||
// button=0 (left), buttons=5 (middle + left). Strict semantics on
|
||||
// pointerdown must reject — user is left-clicking, not middle-clicking.
|
||||
inputEl.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
|
||||
)
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT forward a left pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT forward a right pointerdown', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
expect(processMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointermove — held/bitmask semantic', () => {
|
||||
it('forwards a middle-only pointermove', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards a pointermove when middle is chorded with left (buttons=5)', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards a pointermove when middle is chorded with right (buttons=6)', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 }))
|
||||
expect(processMouseMove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT forward a pointermove with no middle bit held', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 }))
|
||||
expect(processMouseMove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointerup — button field semantic', () => {
|
||||
it('forwards a middle-button pointerup even if buttons is already 0', () => {
|
||||
inputEl.dispatchEvent(
|
||||
new PointerEvent('pointerup', { button: 1, buttons: 0 })
|
||||
)
|
||||
expect(processMouseUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT forward a left pointerup', () => {
|
||||
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 }))
|
||||
expect(processMouseUp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Wires pointerdown / pointermove / pointerup on a DOM widget's input element
|
||||
* so middle-button gestures pass through to the LGraph canvas instead of being
|
||||
* swallowed by the widget surface. Consolidates the three-listener trio that
|
||||
* useStringWidget and useMarkdownWidget would otherwise duplicate.
|
||||
*
|
||||
* Each listener routes through {@link isMiddleForPointerEvent} so pointerdown
|
||||
* gets strict semantics, pointermove survives chorded buttons via the held
|
||||
* bitmask, and pointerup uses the `button` field after release.
|
||||
*
|
||||
* No explicit cleanup is returned: the three listeners are attached directly
|
||||
* to the widget-owned input element and only capture `app.canvas` (a
|
||||
* singleton). When the widget's DOM element is detached and GC'd, the
|
||||
* listeners go with it. If a future widget lifecycle ever rebinds the same
|
||||
* element across instances, this will need to grow a disposer — for now,
|
||||
* simpler is better.
|
||||
*/
|
||||
export function forwardMiddleButtonToCanvas(inputEl: HTMLElement): void {
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (isMiddleForPointerEvent(event)) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if (isMiddleForPointerEvent(event)) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (isMiddleForPointerEvent(event)) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
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
|
After Width: | Height: | Size: 552 B |
BIN
src/scripts/metadata/__fixtures__/with_metadata.flac
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp3
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.mp4
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.opus
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.webm
Normal file
BIN
src/scripts/metadata/__fixtures__/with_metadata.webp
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp
Normal file
|
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'
|
||||
|
||||
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) =>
|
||||
dv.setUint32(off, val, false)
|
||||
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)
|
||||
resolve({})
|
||||
}
|
||||
reader.onabort = () => resolve({})
|
||||
reader.readAsArrayBuffer(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()
|
||||
reader.onload = (event) => handleFileLoad(event, resolve)
|
||||
reader.onerror = () => resolve({})
|
||||
reader.onabort = () => resolve({})
|
||||
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
r(getFromFlacBuffer(arrayBuffer))
|
||||
}
|
||||
reader.onerror = () => r({})
|
||||
reader.onabort = () => r({})
|
||||
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 {
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getGltfBinaryMetadata } from './gltf'
|
||||
|
||||
describe('GLTF binary metadata parser', () => {
|
||||
@@ -160,4 +164,20 @@ describe('GLTF binary metadata parser', () => {
|
||||
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
|
||||
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.onabort = () => resolve({})
|
||||
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
resolve({})
|
||||
}
|
||||
reader.onabort = () => resolve({})
|
||||
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
||||
})
|
||||
}
|
||||
|
||||
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) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
resolve({ templates: jsonContent.templates })
|
||||
return
|
||||
try {
|
||||
if (typeof reader.result !== 'string') {
|
||||
resolve(undefined)
|
||||
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)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
const reader = new FileReader()
|
||||
const read_process = new Promise(
|
||||
(r) => (reader.onload = (event) => r(event?.target?.result))
|
||||
)
|
||||
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||
reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null)
|
||||
reader.onerror = () => r(null)
|
||||
reader.onabort = () => r(null)
|
||||
})
|
||||
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
|
||||
const sig_bytes = new Uint8Array(arrayBuffer, 0, 3)
|
||||
if (
|
||||
(sig_bytes[0] != 0xff && sig_bytes[1] != 0xfb) ||
|
||||
(sig_bytes[0] != 0x49 && sig_bytes[1] != 0x44 && sig_bytes[2] != 0x33)
|
||||
(sig_bytes[0] != 0xff || sig_bytes[1] != 0xfb) &&
|
||||
(sig_bytes[0] != 0x49 || sig_bytes[1] != 0x44 || sig_bytes[2] != 0x33)
|
||||
)
|
||||
console.error('Invalid file signature.')
|
||||
let header = ''
|
||||
while (header.length < arrayBuffer.byteLength) {
|
||||
const page = String.fromCharCode(
|
||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
||||
...new Uint8Array(
|
||||
arrayBuffer,
|
||||
header.length,
|
||||
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||
)
|
||||
)
|
||||
header += page
|
||||
if (page.match('\u00ff\u00fb')) break
|
||||
|
||||
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) {
|
||||
const reader = new FileReader()
|
||||
const read_process = new Promise(
|
||||
(r) => (reader.onload = (event) => r(event?.target?.result))
|
||||
)
|
||||
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||
reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null)
|
||||
reader.onerror = () => r(null)
|
||||
reader.onabort = () => r(null)
|
||||
})
|
||||
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))
|
||||
if (signature !== 'OggS') console.error('Invalid file signature.')
|
||||
let oggs = 0
|
||||
let header = ''
|
||||
while (header.length < arrayBuffer.byteLength) {
|
||||
const page = String.fromCharCode(
|
||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
||||
...new Uint8Array(
|
||||
arrayBuffer,
|
||||
header.length,
|
||||
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||
)
|
||||
)
|
||||
if (page.match('OggS\u0000')) oggs++
|
||||
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(
|
||||
chunkType: string,
|
||||
keyword: string,
|
||||
content: string,
|
||||
content: string | Uint8Array,
|
||||
options: {
|
||||
compressionFlag?: number
|
||||
compressionMethod?: number
|
||||
@@ -20,12 +28,11 @@ function createPngWithChunk(
|
||||
translatedKeyword = ''
|
||||
} = options
|
||||
|
||||
const signature = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
])
|
||||
const signature = new Uint8Array(PNG_SIGNATURE)
|
||||
const typeBytes = new TextEncoder().encode(chunkType)
|
||||
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
|
||||
if (chunkType === 'iTXt') {
|
||||
@@ -66,12 +73,11 @@ function createPngWithChunk(
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
|
||||
const crc = new Uint8Array(4)
|
||||
|
||||
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
|
||||
const total = signature.length + (4 + 4 + chunkData.length + 4) + (4 + 4 + 4)
|
||||
const result = new Uint8Array(total)
|
||||
|
||||
let offset = 0
|
||||
@@ -138,6 +144,21 @@ describe('getFromPngBuffer', () => {
|
||||
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 () => {
|
||||
const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}'
|
||||
const contentBytes = new TextEncoder().encode(workflow)
|
||||
@@ -163,83 +184,49 @@ describe('getFromPngBuffer', () => {
|
||||
pos += chunk.length
|
||||
}
|
||||
|
||||
const buffer = createPngWithCompressedITXt(
|
||||
'workflow',
|
||||
compressedBytes,
|
||||
'',
|
||||
''
|
||||
)
|
||||
const buffer = createPngWithChunk('iTXt', 'workflow', compressedBytes, {
|
||||
compressionFlag: 1,
|
||||
compressionMethod: 0
|
||||
})
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
function createPngWithCompressedITXt(
|
||||
keyword: string,
|
||||
compressedContent: Uint8Array,
|
||||
languageTag: string,
|
||||
translatedKeyword: string
|
||||
): 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)
|
||||
describe('getFromPngFile', () => {
|
||||
it('reads metadata from a File object', async () => {
|
||||
const workflow = '{"nodes":[]}'
|
||||
const buffer = createPngWithChunk('tEXt', 'workflow', workflow)
|
||||
const file = new File([buffer], 'test.png', { type: 'image/png' })
|
||||
|
||||
const totalLength =
|
||||
keywordBytes.length +
|
||||
1 +
|
||||
2 +
|
||||
langBytes.length +
|
||||
1 +
|
||||
transBytes.length +
|
||||
1 +
|
||||
compressedContent.length
|
||||
const result = await getFromPngFile(file)
|
||||
|
||||
const chunkData = new Uint8Array(totalLength)
|
||||
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)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
const lengthBytes = new Uint8Array(4)
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
it('returns empty for an invalid PNG File', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new ArrayBuffer(8)], 'bad.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
const crc = new Uint8Array(4)
|
||||
const iendType = new TextEncoder().encode('IEND')
|
||||
const iendLength = new Uint8Array(4)
|
||||
const iendCrc = new Uint8Array(4)
|
||||
const result = await getFromPngFile(file)
|
||||
|
||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
||||
const result = new Uint8Array(total)
|
||||
expect(result).toEqual({})
|
||||
expect(console.error).toHaveBeenCalledWith('Not a valid PNG file')
|
||||
})
|
||||
|
||||
let offset = 0
|
||||
result.set(signature, offset)
|
||||
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)
|
||||
describe('FileReader failure modes', () => {
|
||||
const file = new File([new Uint8Array(16)], 'test.png')
|
||||
|
||||
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)
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.onabort = () => reject(new Error('FileReader aborted'))
|
||||
reader.readAsArrayBuffer(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 { getFromFlacFile } from './metadata/flac'
|
||||
@@ -21,67 +23,183 @@ vi.mock('./metadata/avif', () => ({
|
||||
getFromAvifFile: vi.fn()
|
||||
}))
|
||||
|
||||
function buildExifPayload(workflowJson: string): Uint8Array {
|
||||
const fullStr = `workflow:${workflowJson}\0`
|
||||
const strBytes = new TextEncoder().encode(fullStr)
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const headerSize = 22
|
||||
const buf = new Uint8Array(headerSize + strBytes.length)
|
||||
const fixturesDir = path.resolve(__dirname, 'metadata/__fixtures__')
|
||||
|
||||
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)
|
||||
|
||||
buf.set([0x49, 0x49], 0)
|
||||
dv.setUint16(2, 0x002a, true)
|
||||
dv.setUint32(4, 8, true)
|
||||
dv.setUint16(8, 1, true)
|
||||
dv.setUint16(10, 0, true)
|
||||
dv.setUint16(12, 2, true)
|
||||
dv.setUint32(14, strBytes.length, true)
|
||||
dv.setUint32(18, 22, true)
|
||||
buf.set(strBytes, 22)
|
||||
dv.setUint16(8, entries.length, true)
|
||||
|
||||
let stringOffset = tableSize
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entryOffset = 10 + i * 12
|
||||
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
|
||||
}
|
||||
|
||||
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
|
||||
const exifPayload = buildExifPayload(workflowJson)
|
||||
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
|
||||
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
|
||||
type WebpChunk = { type: string; payload: Uint8Array }
|
||||
|
||||
const buffer = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buffer.buffer)
|
||||
function wrapInWebp(chunks: WebpChunk[]): File {
|
||||
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)
|
||||
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
|
||||
buf.set([0x57, 0x45, 0x42, 0x50], 8)
|
||||
|
||||
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
|
||||
dv.setUint32(16, precedingChunkLength, true)
|
||||
let offset = 12
|
||||
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
|
||||
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
|
||||
dv.setUint32(exifStart + 4, exifPayload.length, true)
|
||||
buffer.set(exifPayload, exifStart + 8)
|
||||
return new File([buf], 'test.webp', { type: 'image/webp' })
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
|
||||
const workflow = '{"nodes":[]}'
|
||||
const file = buildWebp(3, workflow)
|
||||
it('returns empty when the file is not a valid WEBP', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(12)], 'fake.webp')
|
||||
|
||||
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 () => {
|
||||
const workflow = '{"nodes":[1]}'
|
||||
const file = buildWebp(4, workflow)
|
||||
it('returns empty when a valid WEBP has no EXIF chunk', async () => {
|
||||
const file = wrapInWebp([
|
||||
{ type: 'VP8 ', payload: new Uint8Array([0, 0, 0, 0]) }
|
||||
])
|
||||
|
||||
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}' })
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
)
|
||||
if (chunk_type === 'EXIF') {
|
||||
let exifOffset = offset + 8
|
||||
let exifLength = chunk_length
|
||||
if (
|
||||
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
|
||||
String.fromCharCode(...webp.slice(exifOffset, exifOffset + 6)) ==
|
||||
'Exif\0\0'
|
||||
) {
|
||||
offset += 6
|
||||
exifOffset += 6
|
||||
exifLength -= 6
|
||||
}
|
||||
let data = parseExifData(
|
||||
webp.slice(offset + 8, offset + 8 + chunk_length)
|
||||
const data = parseExifData(
|
||||
webp.slice(exifOffset, exifOffset + exifLength)
|
||||
)
|
||||
for (const key in data) {
|
||||
const value = data[Number(key)]
|
||||
@@ -131,30 +134,38 @@ export function getWebpMetadata(file: File) {
|
||||
|
||||
r(txt_chunks)
|
||||
}
|
||||
|
||||
reader.onerror = () => r({})
|
||||
reader.onabort = () => r({})
|
||||
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) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
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)
|
||||
try {
|
||||
const safetensorsData = new Uint8Array(
|
||||
event.target?.result as ArrayBuffer
|
||||
)
|
||||
)
|
||||
r(header.__metadata__)
|
||||
const dataView = new DataView(safetensorsData.buffer)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
var slice = file.slice(0, 1024 * 1024 * 4)
|
||||
reader.onerror = () => r(undefined)
|
||||
reader.onabort = () => r(undefined)
|
||||
const slice = file.slice(0, 1024 * 1024 * 4)
|
||||
reader.readAsArrayBuffer(slice)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ vi.mock('@/scripts/api', () => ({
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAllAssetsByTag: vi.fn(),
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
invalidateInputAssetsIncludingPublic: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
addAssetTags: vi.fn(),
|
||||
removeAssetTags: vi.fn()
|
||||
@@ -1259,6 +1261,9 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
state: inputAssets,
|
||||
isLoading: inputLoading,
|
||||
error: inputError,
|
||||
execute: updateInputs
|
||||
execute: executeUpdateInputs
|
||||
} = useAsyncState(fetchInputFiles, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
@@ -132,6 +132,12 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const updateInputs = async () => {
|
||||
const result = await executeUpdateInputs()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
|
||||