Compare commits
23 Commits
v1.47.6
...
load-video
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89ab3ae55 | ||
|
|
29da036063 | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
4cb82edc21 | ||
|
|
ca11b77d85 | ||
|
|
d50d219fb1 | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
a2fd9cc1ed | ||
|
|
41ae77681d | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 | ||
|
|
3377b8e07e | ||
|
|
648e2f2383 | ||
|
|
bffa754e70 | ||
|
|
9a1e1d0785 | ||
|
|
2295d78bdd | ||
|
|
24b25b338f | ||
|
|
4a2393be48 | ||
|
|
a451a90868 |
5
.gitignore
vendored
@@ -96,4 +96,7 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.vercel
|
||||
.env*
|
||||
!.env_example
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -26,7 +26,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Cta = { label: string; href: string; target?: '_blank' }
|
||||
|
||||
export interface FeatureStep {
|
||||
id: string
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly FeatureStep[]
|
||||
primaryCta?: Cta
|
||||
secondaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader>{{ heading }}</SectionHeader>
|
||||
|
||||
<!-- Step cards in a row, joined by node-union connectors on desktop -->
|
||||
<div
|
||||
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<template v-for="(step, i) in steps" :key="step.id">
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<NodeUnionIcon
|
||||
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 p-8">
|
||||
<div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ step.number }}
|
||||
</p>
|
||||
<h3
|
||||
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="primaryCta || secondaryCta"
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<Button
|
||||
v-if="primaryCta"
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="
|
||||
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="
|
||||
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
// <video> has no native alt; used as the player's accessible label.
|
||||
alt?: string
|
||||
poster?: string
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
media: RowMedia
|
||||
}
|
||||
|
||||
const {
|
||||
heading,
|
||||
eyebrow,
|
||||
locale = 'en',
|
||||
rows
|
||||
} = defineProps<{
|
||||
heading: string
|
||||
eyebrow?: string
|
||||
locale?: Locale
|
||||
rows: readonly FeatureRow[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" max-width="xl">
|
||||
{{ heading }}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
|
||||
<GlassCard
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.id"
|
||||
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<!-- Text -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ row.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-smoke-700 lg:text-base">
|
||||
{{ row.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="row.media.type === 'image'"
|
||||
:src="row.media.src"
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
:locale="locale"
|
||||
:aria-label="row.media.alt ?? row.title"
|
||||
:src="row.media.src"
|
||||
:poster="row.media.poster"
|
||||
:tracks="row.media.tracks"
|
||||
:autoplay="row.media.autoplay"
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
@@ -27,6 +29,7 @@ const {
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
subtitle,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
@@ -41,14 +44,17 @@ const {
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
videoHideControls = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
subtitle?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
@@ -72,7 +78,8 @@ const {
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -84,7 +91,7 @@ const {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
@@ -93,6 +100,13 @@ const {
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
@@ -127,27 +141,29 @@ const {
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
<slot name="media">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
export interface Reason {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const { highlightClass = 'text-white' } = defineProps<{
|
||||
heading: string
|
||||
headingHighlight?: string
|
||||
highlightClass?: string
|
||||
subtitle?: string
|
||||
reasons: readonly Reason[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
|
||||
>
|
||||
{{ heading
|
||||
}}<span v-if="headingHighlight" :class="highlightClass">{{
|
||||
headingHighlight
|
||||
}}</span>
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right reasons list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason.id"
|
||||
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
|
||||
>
|
||||
<div class="shrink-0 xl:w-84">
|
||||
<h3
|
||||
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ reason.title }}
|
||||
</h3>
|
||||
<slot name="reason-extra" :reason="reason" />
|
||||
</div>
|
||||
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
|
||||
{{ reason.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,12 +7,14 @@ const {
|
||||
label,
|
||||
headingTag = 'h2',
|
||||
maxWidth = 'lg',
|
||||
headingSize = 'section'
|
||||
headingSize = 'section',
|
||||
align = 'center'
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
headingTag?: 'h1' | 'h2' | 'h3'
|
||||
maxWidth?: 'md' | 'lg' | 'xl'
|
||||
headingSize?: 'section' | 'hero'
|
||||
align?: 'center' | 'start'
|
||||
}>()
|
||||
|
||||
const maxWidthClass = {
|
||||
@@ -28,7 +30,14 @@ const headingSizeClass = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
maxWidthClass[maxWidth],
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SectionLabel v-if="label">{{ label }}</SectionLabel>
|
||||
<component
|
||||
:is="headingTag"
|
||||
|
||||
@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
const {
|
||||
value,
|
||||
copyLabel = 'Copy',
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
void copy(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,8 @@ const baseRoutes = {
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -65,6 +66,8 @@ export const externalLinks = {
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
|
||||
@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
header: t('nav.colFeatures', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.mcpServer', locale),
|
||||
href: routes.mcp,
|
||||
badge: 'new'
|
||||
},
|
||||
// TODO: no page yet — re-enable when landing pages ship
|
||||
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
|
||||
// { label: t('nav.appMode', locale), href: '#' },
|
||||
// { label: t('nav.agentSkills', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -11,6 +11,16 @@ const translations = {
|
||||
'zh-CN': '图像生成视频'
|
||||
},
|
||||
|
||||
// UI (global, reusable across sections)
|
||||
'ui.copy': {
|
||||
en: 'Copy',
|
||||
'zh-CN': '复制'
|
||||
},
|
||||
'ui.copied': {
|
||||
en: 'Copied',
|
||||
'zh-CN': '已复制'
|
||||
},
|
||||
|
||||
// CTAs (global, reusable across sections)
|
||||
'cta.tryWorkflow': {
|
||||
en: 'Try Workflow',
|
||||
@@ -1825,6 +1835,311 @@ const translations = {
|
||||
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
|
||||
},
|
||||
|
||||
// MCP – Meta
|
||||
'mcp.meta.title': {
|
||||
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
|
||||
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
|
||||
},
|
||||
'mcp.meta.description': {
|
||||
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
|
||||
},
|
||||
|
||||
// MCP – HeroSection
|
||||
'mcp.hero.heading': {
|
||||
en: 'Drive ComfyUI from\nany AI agent.',
|
||||
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
|
||||
},
|
||||
'mcp.hero.subtitle': {
|
||||
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'mcp.hero.demoPrompt': {
|
||||
en: "match this frame's palette, make the hero key art",
|
||||
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
|
||||
},
|
||||
'mcp.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
},
|
||||
'mcp.hero.demoGenerate': {
|
||||
en: 'GENERATE',
|
||||
'zh-CN': '生成'
|
||||
},
|
||||
'mcp.hero.demoActionGenerateImage': {
|
||||
en: 'GENERATE-IMAGE',
|
||||
'zh-CN': '生成图像'
|
||||
},
|
||||
'mcp.hero.demoActionGenerate3d': {
|
||||
en: 'GENERATE-3D ASSET',
|
||||
'zh-CN': '生成 3D 资产'
|
||||
},
|
||||
'mcp.hero.demoActionUpscale': {
|
||||
en: 'UPSCALE-IMAGE',
|
||||
'zh-CN': '放大图像'
|
||||
},
|
||||
|
||||
// MCP – SetupStepsSection
|
||||
'mcp.setup.label': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'mcp.setup.heading': {
|
||||
en: 'Set up Comfy MCP in three steps',
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
en: 'Connect and sign in',
|
||||
'zh-CN': '连接并登录'
|
||||
},
|
||||
'mcp.setup.step3.description': {
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
'mcp.why.heading': {
|
||||
en: 'Why build on\n',
|
||||
'zh-CN': '为什么选择\n'
|
||||
},
|
||||
'mcp.why.headingHighlight': {
|
||||
en: 'Comfy MCP?',
|
||||
'zh-CN': 'Comfy MCP?'
|
||||
},
|
||||
'mcp.why.subtitle': {
|
||||
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
|
||||
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
|
||||
},
|
||||
'mcp.why.1.title': {
|
||||
en: 'Open protocol,\nany client.',
|
||||
'zh-CN': '开放协议,\n任意客户端。'
|
||||
},
|
||||
'mcp.why.1.description': {
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
|
||||
'zh-CN':
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。'
|
||||
},
|
||||
'mcp.why.2.title': {
|
||||
en: 'The full engine,\nnot a sandbox.',
|
||||
'zh-CN': '完整引擎,\n非沙箱环境。'
|
||||
},
|
||||
'mcp.why.2.description': {
|
||||
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
|
||||
'zh-CN':
|
||||
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
|
||||
},
|
||||
'mcp.why.3.title': {
|
||||
en: 'Outputs you keep.',
|
||||
'zh-CN': '输出归你所有。'
|
||||
},
|
||||
'mcp.why.3.description': {
|
||||
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
|
||||
'zh-CN':
|
||||
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
|
||||
},
|
||||
'mcp.why.4.title': {
|
||||
en: 'Powered by\nComfy Cloud.',
|
||||
'zh-CN': '由 Comfy Cloud\n提供支持。'
|
||||
},
|
||||
'mcp.why.4.description': {
|
||||
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
|
||||
'zh-CN': '无需本地 GPU,通过你团队信赖的相同基础设施运行。'
|
||||
},
|
||||
|
||||
// MCP – ToolsSection
|
||||
'mcp.tools.heading': {
|
||||
en: 'Everything ComfyUI can do,\nnow available as tools.',
|
||||
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
|
||||
},
|
||||
'mcp.tools.1.title': {
|
||||
en: 'Generate anything',
|
||||
'zh-CN': '生成任意内容'
|
||||
},
|
||||
'mcp.tools.1.description': {
|
||||
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
|
||||
},
|
||||
'mcp.tools.1.alt': {
|
||||
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
|
||||
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
|
||||
},
|
||||
'mcp.tools.2.title': {
|
||||
en: 'Search the ecosystem',
|
||||
'zh-CN': '搜索生态系统'
|
||||
},
|
||||
'mcp.tools.2.description': {
|
||||
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
|
||||
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
|
||||
},
|
||||
'mcp.tools.2.alt': {
|
||||
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
|
||||
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
|
||||
},
|
||||
'mcp.tools.3.title': {
|
||||
en: 'Run real workflows',
|
||||
'zh-CN': '运行真实工作流'
|
||||
},
|
||||
'mcp.tools.3.description': {
|
||||
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
|
||||
'zh-CN':
|
||||
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
|
||||
},
|
||||
'mcp.tools.3.alt': {
|
||||
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
|
||||
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
|
||||
},
|
||||
|
||||
// MCP – HowItWorksSection
|
||||
'mcp.howItWorks.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': '工作原理'
|
||||
},
|
||||
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
|
||||
'mcp.howItWorks.step1.title': {
|
||||
en: 'CONNECT',
|
||||
'zh-CN': '连接'
|
||||
},
|
||||
'mcp.howItWorks.step1.description': {
|
||||
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop,通过 OAuth 一次性登录。无需管理 API 密钥。'
|
||||
},
|
||||
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
|
||||
'mcp.howItWorks.step2.title': {
|
||||
en: 'DISCOVER',
|
||||
'zh-CN': '发现'
|
||||
},
|
||||
'mcp.howItWorks.step2.description': {
|
||||
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
|
||||
'zh-CN':
|
||||
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
|
||||
},
|
||||
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
|
||||
'mcp.howItWorks.step3.title': {
|
||||
en: 'CREATE',
|
||||
'zh-CN': '创作'
|
||||
},
|
||||
'mcp.howItWorks.step3.description': {
|
||||
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
|
||||
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
|
||||
},
|
||||
|
||||
// MCP – FAQSection
|
||||
'mcp.faq.heading': {
|
||||
en: 'Q&As',
|
||||
'zh-CN': '常见问答'
|
||||
},
|
||||
'mcp.faq.1.q': {
|
||||
en: 'Which clients are supported?',
|
||||
'zh-CN': '支持哪些客户端?'
|
||||
},
|
||||
'mcp.faq.1.a': {
|
||||
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
|
||||
'zh-CN':
|
||||
'目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
|
||||
},
|
||||
'mcp.faq.2.q': {
|
||||
en: 'Do I need an API key?',
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
|
||||
'zh-CN':
|
||||
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do the slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: "The sign-in didn't open a browser.",
|
||||
'zh-CN': '登录时没有打开浏览器。'
|
||||
},
|
||||
'mcp.faq.4.a': {
|
||||
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
|
||||
'zh-CN':
|
||||
'在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
|
||||
},
|
||||
'mcp.faq.5.q': {
|
||||
en: 'How do I connect in Claude Code?',
|
||||
'zh-CN': '如何在 Claude Code 中连接?'
|
||||
},
|
||||
'mcp.faq.5.a': {
|
||||
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
|
||||
'zh-CN':
|
||||
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。'
|
||||
},
|
||||
'mcp.faq.6.q': {
|
||||
en: "What's the server URL for Claude Desktop?",
|
||||
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'What can my agent do once connected?',
|
||||
'zh-CN': '连接后我的智能体能做什么?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
|
||||
},
|
||||
'mcp.faq.8.q': {
|
||||
en: 'Is it generally available?',
|
||||
'zh-CN': '现已正式发布了吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
|
||||
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
|
||||
},
|
||||
|
||||
// SiteNav
|
||||
'nav.products': { en: 'Products', 'zh-CN': '产品' },
|
||||
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
@@ -1867,6 +2182,7 @@ const translations = {
|
||||
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
|
||||
24
apps/website/src/pages/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
|
||||
import HeroSection from '../templates/mcp/HeroSection.vue'
|
||||
import SetupSection from '../templates/mcp/SetupSection.vue'
|
||||
import WhySection from '../templates/mcp/WhySection.vue'
|
||||
import ToolsSection from '../templates/mcp/ToolsSection.vue'
|
||||
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
|
||||
import FAQSection from '../templates/mcp/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('mcp.meta.title', 'en')}
|
||||
description={t('mcp.meta.description', 'en')}
|
||||
>
|
||||
<HeroSection locale="en" client:load />
|
||||
<SetupSection locale="en" client:visible />
|
||||
<WhySection locale="en" />
|
||||
<ToolsSection locale="en" />
|
||||
<HowItWorksSection locale="en" />
|
||||
<ProductCardsSection locale="en" label-key="products.labelProducts" />
|
||||
<FAQSection client:visible locale="en" />
|
||||
</BaseLayout>
|
||||
24
apps/website/src/pages/zh-CN/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
|
||||
import HeroSection from '../../templates/mcp/HeroSection.vue'
|
||||
import SetupSection from '../../templates/mcp/SetupSection.vue'
|
||||
import WhySection from '../../templates/mcp/WhySection.vue'
|
||||
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
|
||||
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
|
||||
import FAQSection from '../../templates/mcp/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('mcp.meta.title', 'zh-CN')}
|
||||
description={t('mcp.meta.description', 'zh-CN')}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SetupSection locale="zh-CN" client:visible />
|
||||
<WhySection locale="zh-CN" />
|
||||
<ToolsSection locale="zh-CN" />
|
||||
<HowItWorksSection locale="zh-CN" />
|
||||
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
|
||||
<FAQSection client:visible locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -162,6 +162,45 @@
|
||||
animation: ripple-effect 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-cursor-blink {
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.card-slide-enter-active {
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.card-slide-enter-from {
|
||||
transform: translateX(56px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Existing cards slide down smoothly when a new card is prepended. */
|
||||
.card-slide-move {
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.card-slide-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card-slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@utility animate-delay-* {
|
||||
animation-delay: --value([*]);
|
||||
}
|
||||
|
||||
195
apps/website/src/templates/mcp/ComfyMcpDemo.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from '@lucide/vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const PROMPT = t('mcp.hero.demoPrompt', locale)
|
||||
const generateLabel = t('mcp.hero.demoGenerate', locale)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'moodboard_v1.png · 6-up',
|
||||
tag: 'Gmail',
|
||||
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'concepts_01–03.png',
|
||||
tag: 'Notion',
|
||||
thumb: '/images/mcp/mcp-thumb-concepts.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'hero_keyart.png',
|
||||
tag: 'Figma',
|
||||
thumb: '/images/mcp/mcp-thumb-keyart.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerate3d',
|
||||
file: 'asphalt_pbr/ · 5 maps',
|
||||
tag: 'Blender',
|
||||
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionUpscale',
|
||||
file: 'kaiju_neon_4k.png · 4096',
|
||||
tag: null,
|
||||
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
|
||||
}
|
||||
] as const
|
||||
|
||||
const visibleCount = ref(0)
|
||||
const displayedPrompt = ref('')
|
||||
const promptDone = ref(false)
|
||||
|
||||
const displayedCards = computed(() =>
|
||||
cards
|
||||
.slice(0, visibleCount.value)
|
||||
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
|
||||
// Newest card first — it slides in right below the prompt box and pushes
|
||||
// the rest down.
|
||||
.reverse()
|
||||
)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let active = false
|
||||
|
||||
function schedule(fn: () => void, ms: number) {
|
||||
timer = setTimeout(() => {
|
||||
if (active) fn()
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function typePrompt(onDone: () => void) {
|
||||
displayedPrompt.value = ''
|
||||
promptDone.value = false
|
||||
let i = 0
|
||||
|
||||
function step() {
|
||||
i++
|
||||
displayedPrompt.value = PROMPT.slice(0, i)
|
||||
if (i < PROMPT.length) {
|
||||
schedule(step, 35)
|
||||
} else {
|
||||
promptDone.value = true
|
||||
schedule(onDone, 350)
|
||||
}
|
||||
}
|
||||
|
||||
schedule(step, 50)
|
||||
}
|
||||
|
||||
function revealNextCard() {
|
||||
if (visibleCount.value >= cards.length) {
|
||||
// All done — pause then reset
|
||||
schedule(() => {
|
||||
visibleCount.value = 0
|
||||
schedule(revealNextCard, 500)
|
||||
}, 2500)
|
||||
return
|
||||
}
|
||||
|
||||
// Type the prompt, then slide in the next card
|
||||
typePrompt(() => {
|
||||
visibleCount.value++
|
||||
schedule(revealNextCard, 400)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
active = true
|
||||
schedule(revealNextCard, 600)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
|
||||
<!-- Prompt panel -->
|
||||
<div
|
||||
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
|
||||
>
|
||||
<p
|
||||
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ displayedPrompt
|
||||
}}<span
|
||||
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
|
||||
:class="promptDone ? 'animate-cursor-blink' : ''"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-white/10" />
|
||||
<div
|
||||
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
|
||||
>
|
||||
{{ generateLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards accumulate — each slides in from the right after its prompt cycle -->
|
||||
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
|
||||
<TransitionGroup
|
||||
name="card-slide"
|
||||
tag="div"
|
||||
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
|
||||
>
|
||||
<div
|
||||
v-for="(card, i) in displayedCards"
|
||||
:key="card.file"
|
||||
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
|
||||
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
|
||||
>
|
||||
<img
|
||||
:src="card.thumb"
|
||||
:alt="card.action"
|
||||
class="size-13.5 shrink-0 rounded-[14px] object-cover"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p
|
||||
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
|
||||
>
|
||||
{{ card.action }}
|
||||
</p>
|
||||
<p
|
||||
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ card.file }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="card.tag"
|
||||
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ card.tag }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Check
|
||||
class="size-4 shrink-0 text-primary-comfy-canvas/60"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Bottom fade so accumulating cards dissolve into the page background -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
apps/website/src/templates/mcp/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
const faqs = faqNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
question: t(`mcp.faq.${n}.q`, locale),
|
||||
answer: t(`mcp.faq.${n}.a`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
27
apps/website/src/templates/mcp/HeroSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import ComfyMcpDemo from './ComfyMcpDemo.vue'
|
||||
import { mcpCtas } from './ctas'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctas = mcpCtas(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroSplit01
|
||||
:locale="locale"
|
||||
class="min-h-screen"
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
<ComfyMcpDemo :locale="locale" />
|
||||
</template>
|
||||
</HeroSplit01>
|
||||
</template>
|
||||
29
apps/website/src/templates/mcp/HowItWorksSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
|
||||
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { mcpCtas } from './ctas'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctas = mcpCtas(locale)
|
||||
|
||||
const stepNumbers = [1, 2, 3] as const
|
||||
|
||||
const steps: FeatureStep[] = stepNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
number: t(`mcp.howItWorks.step${n}.number`, locale),
|
||||
title: t(`mcp.howItWorks.step${n}.title`, locale),
|
||||
description: t(`mcp.howItWorks.step${n}.description`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid02
|
||||
:heading="t('mcp.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
/>
|
||||
</template>
|
||||
64
apps/website/src/templates/mcp/SetupSection.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
|
||||
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards: FeatureCard[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
label: t('mcp.setup.step1.label', locale),
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
label: t('mcp.setup.step2.label', locale),
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
:columns="3"
|
||||
:cards="cards"
|
||||
:copy-label="t('ui.copy', locale)"
|
||||
:copied-label="t('ui.copied', locale)"
|
||||
/>
|
||||
</template>
|
||||
66
apps/website/src/templates/mcp/ToolsSection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
|
||||
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type ToolMedia =
|
||||
| { type: 'image'; src: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
{
|
||||
n: 1,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
|
||||
},
|
||||
altKey: 'mcp.tools.1.alt'
|
||||
},
|
||||
{
|
||||
n: 2,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
|
||||
},
|
||||
altKey: 'mcp.tools.2.alt'
|
||||
},
|
||||
{
|
||||
n: 3,
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true
|
||||
},
|
||||
altKey: 'mcp.tools.3.alt'
|
||||
}
|
||||
]
|
||||
|
||||
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
|
||||
const alt = altKey ? t(altKey, locale) : undefined
|
||||
return {
|
||||
id: String(n),
|
||||
title: t(`mcp.tools.${n}.title`, locale),
|
||||
description: t(`mcp.tools.${n}.description`, locale),
|
||||
media: { ...media, alt }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureRows01
|
||||
:locale="locale"
|
||||
:heading="t('mcp.tools.heading', locale)"
|
||||
:rows="rows"
|
||||
/>
|
||||
</template>
|
||||
26
apps/website/src/templates/mcp/WhySection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
|
||||
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasonNumbers = [1, 2, 3, 4] as const
|
||||
|
||||
const reasons: Reason[] = reasonNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
title: t(`mcp.why.${n}.title`, locale),
|
||||
description: t(`mcp.why.${n}.description`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReasonsSplit01
|
||||
:heading="t('mcp.why.heading', locale)"
|
||||
:heading-highlight="t('mcp.why.headingHighlight', locale)"
|
||||
highlight-class="text-primary-comfy-yellow"
|
||||
:subtitle="t('mcp.why.subtitle', locale)"
|
||||
:reasons="reasons"
|
||||
/>
|
||||
</template>
|
||||
27
apps/website/src/templates/mcp/ctas.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export interface McpCta {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
}
|
||||
|
||||
/**
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
export type VideoSource = {
|
||||
type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
|
||||
type ComboInput = ComboInputSpec | ComboInputSpecV2
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
|
||||
|
||||
export interface PromotedMissingModelWorkflow {
|
||||
interface PromotedMissingModelWorkflow {
|
||||
workflowName: string
|
||||
hostNodeId: number
|
||||
hostNodeTitle: string
|
||||
|
||||
@@ -266,6 +266,9 @@
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-400);
|
||||
|
||||
--video-trim-selection-background: var(--color-datatype-CLIP, #ffd500);
|
||||
--video-trim-playhead-background: #f0513b;
|
||||
|
||||
/* Default UI element color palette variables */
|
||||
--palette-contrast-mix-color: #fff;
|
||||
--palette-interface-panel-surface: var(--comfy-menu-bg);
|
||||
@@ -549,6 +552,10 @@
|
||||
);
|
||||
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
|
||||
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
|
||||
--color-video-trim-selection-background: var(
|
||||
--video-trim-selection-background
|
||||
);
|
||||
--color-video-trim-playhead-background: var(--video-trim-playhead-background);
|
||||
|
||||
/* Semantic tokens */
|
||||
--color-base-foreground: var(--base-foreground);
|
||||
|
||||
@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
|
||||
@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu button-class="icon-[lucide--list-filter]">
|
||||
<DropdownMenu>
|
||||
<template #button>
|
||||
<Button size="icon" :aria-label="$t('g.filter')">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
|
||||
>
|
||||
<span
|
||||
class="flex-1"
|
||||
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
|
||||
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
|
||||
/>
|
||||
<DropdownMenuItemIndicator class="size-4 shrink-0">
|
||||
<i class="icon-[lucide--check]" />
|
||||
|
||||
@@ -39,7 +39,11 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
const advancedWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
w.options?.hideInPanel
|
||||
) && w.options?.advanced
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: advancedWidgets, node }
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
computedSectionDataList,
|
||||
flatAndCategorizeSelectedItems,
|
||||
searchWidgets,
|
||||
searchWidgetsAndNodes
|
||||
@@ -129,6 +135,51 @@ describe('searchWidgetsAndNodes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computedSectionDataList', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
options: IWidgetOptions = {}
|
||||
): IBaseWidget {
|
||||
return { name, type: 'number', options, y: 0 } as IBaseWidget
|
||||
}
|
||||
|
||||
it('omits hideInPanel widgets while keeping the rest on the node', () => {
|
||||
const node = new LGraphNode('Load3D')
|
||||
node.widgets = [
|
||||
createWidget('seed'),
|
||||
createWidget('viewport', { hideInPanel: true })
|
||||
]
|
||||
|
||||
const { widgetsSectionDataList } = computedSectionDataList([node])
|
||||
const shownNames = widgetsSectionDataList.value[0].widgets.map(
|
||||
({ widget }) => widget.name
|
||||
)
|
||||
|
||||
expect(shownNames).toEqual(['seed'])
|
||||
})
|
||||
|
||||
it('hides canvasOnly, hidden, and hideInPanel widgets from the panel', () => {
|
||||
const node = new LGraphNode('Load3D')
|
||||
node.widgets = [
|
||||
createWidget('seed'),
|
||||
createWidget('preview', { canvasOnly: true }),
|
||||
createWidget('internal', { hidden: true }),
|
||||
createWidget('viewport', { hideInPanel: true })
|
||||
]
|
||||
|
||||
const { widgetsSectionDataList } = computedSectionDataList([node])
|
||||
const shownNames = widgetsSectionDataList.value[0].widgets.map(
|
||||
({ widget }) => widget.name
|
||||
)
|
||||
|
||||
expect(shownNames).toEqual(['seed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flatAndCategorizeSelectedItems', () => {
|
||||
let testGroup1: LGraphGroup
|
||||
let testGroup2: LGraphGroup
|
||||
|
||||
@@ -263,6 +263,7 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
w.options?.hideInPanel ||
|
||||
(w.options?.advanced && !includesAdvanced.value)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
|
||||
import {
|
||||
|
||||
16
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TooltipRootProps>()
|
||||
const emits = defineEmits<TooltipRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
62
src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||
import {
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
sideOffset = 4,
|
||||
class: className,
|
||||
arrowClass,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
TooltipContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
arrowClass?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<TooltipContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
sideOffset,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-fit rounded-md border bg-base-background px-3 py-1.5 text-sm text-base-foreground shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<TooltipArrow
|
||||
:class="cn('fill-base-background', arrowClass)"
|
||||
:width="10"
|
||||
:height="5"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
68
src/components/ui/tooltip/TooltipHint.stories.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TooltipHint from './TooltipHint.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const meta: Meta<typeof TooltipHint> = {
|
||||
title: 'Components/Tooltip/TooltipHint',
|
||||
component: TooltipHint,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
content: 'Tooltip hint',
|
||||
side: 'top',
|
||||
delayDuration: 300,
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { TooltipHint, Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center p-16">
|
||||
<TooltipHint v-bind="args">
|
||||
<Button variant="secondary">Hover me</Button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
content: 'Hidden tooltip'
|
||||
},
|
||||
render: Default.render
|
||||
}
|
||||
|
||||
export const IconButton: Story = {
|
||||
args: {
|
||||
content: 'Set start frame'
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { TooltipHint },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center p-16">
|
||||
<TooltipHint v-bind="args">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-component-node-widget-background text-component-node-foreground"
|
||||
aria-label="Set start frame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
45
src/components/ui/tooltip/TooltipHint.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentProps } from 'reka-ui'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipProvider from '@/components/ui/tooltip/TooltipProvider.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
content,
|
||||
side = 'top',
|
||||
sideOffset = 4,
|
||||
delayDuration = 300,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
content: string
|
||||
side?: TooltipContentProps['side']
|
||||
sideOffset?: number
|
||||
delayDuration?: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="delayDuration">
|
||||
<Tooltip :disabled="disabled">
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
:side
|
||||
:side-offset="sideOffset"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2.5 py-1 text-xs leading-none text-node-component-tooltip shadow-none'
|
||||
)
|
||||
"
|
||||
arrow-class="fill-node-component-tooltip-surface"
|
||||
>
|
||||
{{ content }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
12
src/components/ui/tooltip/TooltipProvider.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipProviderProps } from 'reka-ui'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
|
||||
const { ...restProps } = defineProps<TooltipProviderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="restProps">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
12
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipTriggerProps } from 'reka-ui'
|
||||
import { TooltipTrigger } from 'reka-ui'
|
||||
|
||||
const { ...restProps } = defineProps<TooltipTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger v-bind="restProps">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
169
src/components/video/LoadVideoTrimPanel.stories.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref, toRefs } from 'vue'
|
||||
|
||||
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
|
||||
|
||||
const SAMPLE_VIDEO =
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof LoadVideoTrimPanel> & {
|
||||
trimEnabled?: boolean
|
||||
startFrame?: number
|
||||
endFrame?: number
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Video/LoadVideoTrimPanel',
|
||||
component: LoadVideoTrimPanel,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 400
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderPanel(initialTrimEnabled: boolean) {
|
||||
return (args: StoryArgs) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const { videoUrl } = toRefs(args)
|
||||
const trimEnabled = ref(initialTrimEnabled)
|
||||
const startFrame = ref(args.startFrame ?? 0)
|
||||
const endFrame = ref(args.endFrame ?? 400)
|
||||
const playheadFrame = ref(0)
|
||||
return {
|
||||
videoUrl,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const TrimDisabled: Story = {
|
||||
render: renderPanel(false)
|
||||
}
|
||||
|
||||
export const TrimEnabled: Story = {
|
||||
render: renderPanel(true)
|
||||
}
|
||||
|
||||
export const EmptyNoVideo: Story = {
|
||||
args: {
|
||||
videoUrl: undefined
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const trimEnabled = ref(false)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(0)
|
||||
const playheadFrame = ref(0)
|
||||
const uploading = ref(false)
|
||||
function handleBrowse() {
|
||||
uploading.value = true
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
}, 1200)
|
||||
}
|
||||
return {
|
||||
args,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame,
|
||||
uploading,
|
||||
handleBrowse
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="args.videoUrl"
|
||||
:uploading="uploading"
|
||||
@browse="handleBrowse"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const EmptyNodeLayout: Story = {
|
||||
args: {
|
||||
videoUrl: undefined
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const trimEnabled = ref(false)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(0)
|
||||
const playheadFrame = ref(0)
|
||||
const uploading = ref(false)
|
||||
return {
|
||||
args,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame,
|
||||
uploading
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="px-2">
|
||||
<label class="mb-1 block text-sm text-muted-foreground">video</label>
|
||||
<div class="flex h-8 items-center justify-between rounded-lg bg-component-node-widget-background px-2 text-sm text-text-secondary">
|
||||
<span>Browse asset library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-component-node-foreground-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="args.videoUrl"
|
||||
:uploading="uploading"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongVideoManyFrames: Story = {
|
||||
args: {
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
startFrame: 120,
|
||||
endFrame: 3600
|
||||
},
|
||||
render: renderPanel(true)
|
||||
}
|
||||
446
src/components/video/LoadVideoTrimPanel.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
|
||||
|
||||
vi.mock('@/composables/video/useVideoFilmstrip', () => ({
|
||||
DEFAULT_VIDEO_FPS: 30,
|
||||
useVideoFilmstrip: () => ({
|
||||
thumbnails: ref<string[]>(['data:image/jpeg;base64,one']),
|
||||
duration: ref(10),
|
||||
totalFrames: ref(101),
|
||||
width: ref(1920),
|
||||
height: ref(1080),
|
||||
fps: ref(30),
|
||||
fileSize: ref(5 * 1024 * 1024),
|
||||
loading: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
increment: 'Increment',
|
||||
decrement: 'Decrement',
|
||||
remove: 'Remove'
|
||||
},
|
||||
loadVideoTrim: {
|
||||
trimVideo: 'Trim Video',
|
||||
startFrame: 'Start Frame',
|
||||
endFrame: 'End Frame',
|
||||
setStartFrame: 'Set start frame',
|
||||
setEndFrame: 'Set end frame',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
adjustStartFrame: 'Adjust start frame',
|
||||
adjustEndFrame: 'Adjust end frame',
|
||||
duration: 'Duration',
|
||||
frames: 'Number of Frames',
|
||||
fileSize: 'File Size',
|
||||
durationZero: '0s',
|
||||
durationSeconds: '{count}s',
|
||||
fileSizeUnknown: '—',
|
||||
fileSizeBytes: '{count} B',
|
||||
fileSizeKilobytes: '{count} KB',
|
||||
fileSizeMegabytes: '{count} MB',
|
||||
resolution: '{width} × {height}',
|
||||
dragAndDropVideos: 'Drag and drop videos here to upload',
|
||||
uploadFromDevice: 'Upload from device',
|
||||
uploading: 'Uploading…',
|
||||
loadingVideo: 'Loading video preview'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type PanelProps = ComponentProps<typeof LoadVideoTrimPanel>
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function renderPanel(props: PanelProps) {
|
||||
return render(LoadVideoTrimPanel, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoadVideoTrimPanel', () => {
|
||||
it('shows upload empty state and hides trim controls when no video', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('media-upload-empty')).toBeTruthy()
|
||||
expect(screen.queryByText('Trim Video')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows trim controls when video is loaded', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('media-upload-empty')).toBeNull()
|
||||
expect(screen.getByText('Trim Video')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the filmstrip visible when trim is toggled off', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trim-track')).toBeTruthy()
|
||||
expect(screen.queryByText('Start Frame')).toBeNull()
|
||||
expect(screen.queryByText('End Frame')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows drag and drop empty state while not uploading', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined,
|
||||
uploading: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
|
||||
expect(screen.queryByText('Uploading…')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows uploading state only while an upload is in progress', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined,
|
||||
uploading: true
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
expect(screen.getByText('Uploading…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows remove button and emits remove when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
const removeButton = screen.getByTestId('video-remove-button')
|
||||
expect(removeButton).toBeTruthy()
|
||||
expect(removeButton).toHaveAttribute('aria-label', 'Remove')
|
||||
|
||||
await user.click(removeButton)
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('activates remove from keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
const removeButton = screen.getByTestId('video-remove-button')
|
||||
removeButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('forwards browse event from empty state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: undefined
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps playhead when trim edges move without collision', async () => {
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
startFrame.value = 20
|
||||
await Promise.resolve()
|
||||
|
||||
expect(playheadFrame.value).toBe(50)
|
||||
})
|
||||
|
||||
it('moves playhead when trim edge collides with it', async () => {
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
startFrame.value = 60
|
||||
await Promise.resolve()
|
||||
|
||||
expect(playheadFrame.value).toBe(60)
|
||||
})
|
||||
|
||||
it('moves playhead when start frame increment passes playhead', async () => {
|
||||
const user = userEvent.setup()
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(50)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getAllByTestId('increment')[0])
|
||||
|
||||
expect(startFrame.value).toBe(51)
|
||||
expect(playheadFrame.value).toBe(51)
|
||||
})
|
||||
|
||||
it('disables set start and end frame when trim handles are at defaults', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: true,
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 0
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Set start frame')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables set end frame when trim end is already at the last frame', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: true,
|
||||
startFrame: 10,
|
||||
endFrame: 100,
|
||||
playheadFrame: 50
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Set start frame')).not.toBeDisabled()
|
||||
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('resets the start trim handle to the first frame', async () => {
|
||||
const user = userEvent.setup()
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(100)
|
||||
const playheadFrame = ref(50)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getByLabelText('Set start frame'))
|
||||
|
||||
expect(startFrame.value).toBe(0)
|
||||
expect(playheadFrame.value).toBe(0)
|
||||
})
|
||||
|
||||
it('resets the end trim handle to the last frame', async () => {
|
||||
const user = userEvent.setup()
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
const playheadFrame = ref(50)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getByLabelText('Set end frame'))
|
||||
|
||||
expect(endFrame.value).toBe(100)
|
||||
expect(playheadFrame.value).toBe(100)
|
||||
})
|
||||
|
||||
it('seeks the video preview when scrubbing the filmstrip', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(100)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
const video = screen.getByTestId('video-preview') as HTMLVideoElement
|
||||
let currentTime = 0
|
||||
Object.defineProperty(video, 'currentTime', {
|
||||
get: () => currentTime,
|
||||
set: (value: number) => {
|
||||
currentTime = value
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(video, 'duration', {
|
||||
value: 10,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
await fireEvent.loadedMetadata(video)
|
||||
await flushPromises()
|
||||
await fireEvent.seeked(video)
|
||||
await flushPromises()
|
||||
|
||||
const track = screen.getByTestId('trim-track')
|
||||
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 64,
|
||||
right: 200,
|
||||
bottom: 64,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
track.setPointerCapture = vi.fn()
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events
|
||||
await fireEvent.pointerDown(track, {
|
||||
clientX: 100,
|
||||
button: 0,
|
||||
pointerId: 1
|
||||
})
|
||||
await flushPromises()
|
||||
await fireEvent.seeked(video)
|
||||
await flushPromises()
|
||||
|
||||
expect(playheadFrame.value).toBe(50)
|
||||
expect(currentTime).toBe(5)
|
||||
})
|
||||
})
|
||||
501
src/components/video/LoadVideoTrimPanel.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
:class="!videoUrl && 'min-h-0 flex-1 pb-3'"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<MediaUploadEmpty
|
||||
v-if="!videoUrl"
|
||||
fill
|
||||
accept="video/*"
|
||||
:disabled="uploadDisabled"
|
||||
:uploading
|
||||
:on-drag-over
|
||||
:on-drag-drop
|
||||
@browse="emit('browse')"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-testid="video-preview-container"
|
||||
class="group relative w-full"
|
||||
:style="videoAspectRatioStyle"
|
||||
>
|
||||
<div
|
||||
class="relative size-full overflow-hidden rounded-lg bg-node-component-surface"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
data-testid="video-preview"
|
||||
:src="videoUrl"
|
||||
class="size-full object-contain"
|
||||
preload="auto"
|
||||
muted
|
||||
playsinline
|
||||
@loadedmetadata="handleVideoMetadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
/>
|
||||
<div
|
||||
v-if="filmstripLoading"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-0 bg-node-component-surface"
|
||||
data-testid="video-preview-loading"
|
||||
:aria-busy="true"
|
||||
:aria-label="t('loadVideoTrim.loadingVideo')"
|
||||
>
|
||||
<Loader size="md" variant="loader-circle" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('loadVideoTrim.loadingVideo') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipHint v-if="!filmstripLoading" :content="t('g.remove')">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-remove-button"
|
||||
:class="
|
||||
cn(
|
||||
removeButtonClass,
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="t('g.remove')"
|
||||
@pointerdown.stop
|
||||
@click.stop="emit('remove')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoUrl"
|
||||
class="grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-1"
|
||||
>
|
||||
<WidgetToggleSwitch
|
||||
v-model="trimEnabled"
|
||||
class="col-span-full grid grid-cols-subgrid"
|
||||
:widget="trimToggleWidget"
|
||||
/>
|
||||
|
||||
<VideoFilmstripTrim
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:is-playing="isPlaying"
|
||||
class="col-span-full mt-2"
|
||||
:trim-enabled="trimEnabled"
|
||||
:total-frames="effectiveTotalFrames"
|
||||
:thumbnails="thumbnails"
|
||||
@scrub="handleScrub"
|
||||
/>
|
||||
|
||||
<WidgetInputNumberInput
|
||||
v-if="trimEnabled"
|
||||
v-model="startFrame"
|
||||
root-class="col-span-full grid grid-cols-subgrid items-center"
|
||||
:widget="startFrameWidget"
|
||||
/>
|
||||
|
||||
<WidgetInputNumberInput
|
||||
v-if="trimEnabled"
|
||||
v-model="endFrame"
|
||||
root-class="col-span-full grid grid-cols-subgrid items-center"
|
||||
:widget="endFrameWidget"
|
||||
/>
|
||||
|
||||
<div v-if="trimEnabled" class="col-span-full grid grid-cols-2 gap-1">
|
||||
<TooltipHint
|
||||
:content="t('loadVideoTrim.setStartFrame')"
|
||||
:disabled="setStartFrameDisabled"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="WidgetInputActionButtonClass"
|
||||
:disabled="setStartFrameDisabled"
|
||||
:aria-label="t('loadVideoTrim.setStartFrame')"
|
||||
@click="setStartFrame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
<TooltipHint
|
||||
:content="t('loadVideoTrim.setEndFrame')"
|
||||
:disabled="setEndFrameDisabled"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="WidgetInputActionButtonClass"
|
||||
:disabled="setEndFrameDisabled"
|
||||
:aria-label="t('loadVideoTrim.setEndFrame')"
|
||||
@click="setEndFrame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-forward] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-full mt-2 grid grid-cols-subgrid gap-y-0.5 border-t border-node-stroke py-2"
|
||||
>
|
||||
<div
|
||||
v-for="row in metadataRows"
|
||||
:key="row.label"
|
||||
class="col-span-full grid grid-cols-subgrid py-0.5 text-sm"
|
||||
>
|
||||
<span class="truncate text-muted-foreground">{{ row.label }}</span>
|
||||
<span class="text-right text-base-foreground">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="resolutionLabel"
|
||||
class="col-span-full m-0 border-t border-node-stroke py-3 text-center text-sm text-base-foreground"
|
||||
>
|
||||
{{ resolutionLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import MediaUploadEmpty from '@/components/video/MediaUploadEmpty.vue'
|
||||
import VideoFilmstripTrim from '@/components/video/VideoFilmstripTrim.vue'
|
||||
import TooltipHint from '@/components/ui/tooltip/TooltipHint.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DEFAULT_VIDEO_FPS,
|
||||
useVideoFilmstrip
|
||||
} from '@/composables/video/useVideoFilmstrip'
|
||||
import { WidgetInputActionButtonClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue'
|
||||
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const {
|
||||
videoUrl,
|
||||
uploading = false,
|
||||
uploadDisabled = false,
|
||||
onDragOver,
|
||||
onDragDrop
|
||||
} = defineProps<{
|
||||
videoUrl?: string
|
||||
uploading?: boolean
|
||||
uploadDisabled?: boolean
|
||||
onDragOver?: (event: DragEvent) => boolean
|
||||
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const removeButtonClass =
|
||||
'absolute top-2 right-2 z-10 flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-transparent'
|
||||
|
||||
const trimEnabled = defineModel<boolean>('trimEnabled', { default: false })
|
||||
const startFrame = defineModel<number>('startFrame', { default: 0 })
|
||||
const endFrame = defineModel<number>('endFrame', { default: 0 })
|
||||
const playheadFrame = defineModel<number>('playheadFrame', { default: 0 })
|
||||
|
||||
const { t } = useI18n()
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
const isPlaying = ref(false)
|
||||
const isSeeking = ref(false)
|
||||
const videoIntrinsicSize = ref<{ width: number; height: number } | null>(null)
|
||||
let activeSeekId = 0
|
||||
|
||||
const videoUrlRef = computed(() => videoUrl)
|
||||
const {
|
||||
thumbnails,
|
||||
duration,
|
||||
totalFrames,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
fileSize,
|
||||
loading: filmstripLoading
|
||||
} = useVideoFilmstrip(videoUrlRef)
|
||||
|
||||
const effectiveTotalFrames = computed(() => Math.max(totalFrames.value, 1))
|
||||
|
||||
const frameMax = computed(() => Math.max(totalFrames.value - 1, 0))
|
||||
|
||||
const controlsDisabled = computed(() => !trimEnabled.value || !videoUrl)
|
||||
|
||||
const setStartFrameDisabled = computed(
|
||||
() => controlsDisabled.value || startFrame.value <= 0
|
||||
)
|
||||
|
||||
const setEndFrameDisabled = computed(
|
||||
() => controlsDisabled.value || endFrame.value >= frameMax.value
|
||||
)
|
||||
|
||||
const trimToggleWidget = computed(
|
||||
(): SimplifiedWidget<boolean> => ({
|
||||
name: 'trim_enabled',
|
||||
label: t('loadVideoTrim.trimVideo'),
|
||||
type: 'toggle',
|
||||
value: trimEnabled.value
|
||||
})
|
||||
)
|
||||
|
||||
const startFrameWidget = computed(
|
||||
(): SimplifiedWidget<number> => ({
|
||||
name: 'start_frame',
|
||||
label: t('loadVideoTrim.startFrame'),
|
||||
type: 'number',
|
||||
value: startFrame.value,
|
||||
options: {
|
||||
min: 0,
|
||||
max: Math.max(endFrame.value - 1, 0),
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
disabled: !videoUrl
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const endFrameWidget = computed(
|
||||
(): SimplifiedWidget<number> => ({
|
||||
name: 'end_frame',
|
||||
label: t('loadVideoTrim.endFrame'),
|
||||
type: 'number',
|
||||
value: endFrame.value,
|
||||
options: {
|
||||
min: Math.min(startFrame.value + 1, effectiveTotalFrames.value - 1),
|
||||
max: Math.max(effectiveTotalFrames.value - 1, 0),
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
disabled: !videoUrl
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const videoAspectRatioStyle = computed(() => {
|
||||
const intrinsic = videoIntrinsicSize.value
|
||||
const aspectWidth = width.value || intrinsic?.width
|
||||
const aspectHeight = height.value || intrinsic?.height
|
||||
if (aspectWidth && aspectHeight) {
|
||||
return { aspectRatio: `${aspectWidth} / ${aspectHeight}` }
|
||||
}
|
||||
return { aspectRatio: '16 / 9' }
|
||||
})
|
||||
|
||||
const metadataRows = computed(() => [
|
||||
{
|
||||
label: t('loadVideoTrim.duration'),
|
||||
value: formatDuration(duration.value)
|
||||
},
|
||||
{
|
||||
label: t('loadVideoTrim.frames'),
|
||||
value: String(effectiveTotalFrames.value)
|
||||
},
|
||||
{
|
||||
label: t('loadVideoTrim.fileSize'),
|
||||
value: formatFileSize(fileSize.value)
|
||||
}
|
||||
])
|
||||
|
||||
const resolutionLabel = computed(() => {
|
||||
const intrinsic = videoIntrinsicSize.value
|
||||
const displayWidth = width.value || intrinsic?.width
|
||||
const displayHeight = height.value || intrinsic?.height
|
||||
if (!displayWidth || !displayHeight) return ''
|
||||
return t('loadVideoTrim.resolution', {
|
||||
width: displayWidth,
|
||||
height: displayHeight
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => videoUrl,
|
||||
() => {
|
||||
startFrame.value = 0
|
||||
playheadFrame.value = 0
|
||||
endFrame.value = 0
|
||||
isPlaying.value = false
|
||||
videoIntrinsicSize.value = null
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
totalFrames,
|
||||
(frames) => {
|
||||
if (!videoUrl || frames <= 0) return
|
||||
const lastFrame = Math.max(frames - 1, 0)
|
||||
if (endFrame.value === 0 || endFrame.value > lastFrame) {
|
||||
endFrame.value = lastFrame
|
||||
}
|
||||
playheadFrame.value = clamp(playheadFrame.value, 0, frameMax.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([startFrame, endFrame], ([start, end]) => {
|
||||
if (start >= end && end > 0) {
|
||||
startFrame.value = Math.max(end - 1, 0)
|
||||
}
|
||||
resolvePlayheadTrimCollision()
|
||||
})
|
||||
|
||||
watch(isPlaying, (playing) => {
|
||||
void handlePlaybackChange(playing)
|
||||
})
|
||||
|
||||
async function handlePlaybackChange(playing: boolean) {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
if (playing) {
|
||||
const startAt = trimEnabled.value
|
||||
? clamp(playheadFrame.value, startFrame.value, endFrame.value)
|
||||
: clamp(playheadFrame.value, 0, frameMax.value)
|
||||
await seekPreviewToFrame(startAt)
|
||||
if (!isPlaying.value) return
|
||||
try {
|
||||
await video.play()
|
||||
} catch {
|
||||
isPlaying.value = false
|
||||
}
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function frameToTime(frame: number) {
|
||||
if (duration.value > 0 && frameMax.value > 0) {
|
||||
return (frame / frameMax.value) * duration.value
|
||||
}
|
||||
return frame / (fps.value || DEFAULT_VIDEO_FPS)
|
||||
}
|
||||
|
||||
function clampSeekTime(video: HTMLVideoElement, time: number) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0) {
|
||||
return Math.max(time, 0)
|
||||
}
|
||||
return clamp(time, 0, Math.max(video.duration - 0.001, 0))
|
||||
}
|
||||
|
||||
function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
video.removeEventListener('seeked', finish)
|
||||
video.removeEventListener('error', finish)
|
||||
resolve()
|
||||
}
|
||||
video.addEventListener('seeked', finish, { once: true })
|
||||
video.addEventListener('error', finish, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function seekPreviewToFrame(frame: number) {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
|
||||
const clamped = clamp(frame, 0, frameMax.value)
|
||||
playheadFrame.value = clamped
|
||||
|
||||
const targetTime = clampSeekTime(video, frameToTime(clamped))
|
||||
if (Math.abs(video.currentTime - targetTime) <= 0.0001) return
|
||||
|
||||
const seekId = ++activeSeekId
|
||||
isSeeking.value = true
|
||||
video.currentTime = targetTime
|
||||
await waitForVideoSeek(video)
|
||||
|
||||
if (seekId === activeSeekId) {
|
||||
isSeeking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlayheadTrimCollision() {
|
||||
if (!trimEnabled.value) return
|
||||
|
||||
const start = startFrame.value
|
||||
const end = endFrame.value
|
||||
const previous = playheadFrame.value
|
||||
if (previous < start) {
|
||||
playheadFrame.value = start
|
||||
} else if (previous > end) {
|
||||
playheadFrame.value = end
|
||||
}
|
||||
if (!isPlaying.value && playheadFrame.value !== previous) {
|
||||
void seekPreviewToFrame(playheadFrame.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrub(frame: number) {
|
||||
isPlaying.value = false
|
||||
void seekPreviewToFrame(frame)
|
||||
}
|
||||
|
||||
function handleVideoMetadata() {
|
||||
const video = videoRef.value
|
||||
if (video?.videoWidth && video.videoHeight) {
|
||||
videoIntrinsicSize.value = {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
}
|
||||
}
|
||||
void seekPreviewToFrame(playheadFrame.value)
|
||||
}
|
||||
|
||||
function timeToFrame(time: number) {
|
||||
if (duration.value > 0 && frameMax.value > 0) {
|
||||
return Math.round((time / duration.value) * frameMax.value)
|
||||
}
|
||||
return Math.round(time * (fps.value || DEFAULT_VIDEO_FPS))
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
const video = videoRef.value
|
||||
if (!video || !isPlaying.value || isSeeking.value) return
|
||||
|
||||
const frame = timeToFrame(video.currentTime)
|
||||
const minFrame = trimEnabled.value ? startFrame.value : 0
|
||||
const maxFrame = trimEnabled.value ? endFrame.value : frameMax.value
|
||||
playheadFrame.value = clamp(frame, minFrame, maxFrame)
|
||||
|
||||
if (frame >= maxFrame) {
|
||||
isPlaying.value = false
|
||||
void seekPreviewToFrame(maxFrame)
|
||||
}
|
||||
}
|
||||
|
||||
function setStartFrame() {
|
||||
isPlaying.value = false
|
||||
startFrame.value = 0
|
||||
void seekPreviewToFrame(0)
|
||||
}
|
||||
|
||||
function setEndFrame() {
|
||||
isPlaying.value = false
|
||||
endFrame.value = frameMax.value
|
||||
void seekPreviewToFrame(frameMax.value)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!seconds) return t('loadVideoTrim.durationZero')
|
||||
return t('loadVideoTrim.durationSeconds', { count: Math.round(seconds) })
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number) {
|
||||
if (bytes == null) return t('loadVideoTrim.fileSizeUnknown')
|
||||
if (bytes < 1024) {
|
||||
return t('loadVideoTrim.fileSizeBytes', { count: bytes })
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return t('loadVideoTrim.fileSizeKilobytes', {
|
||||
count: Math.round(bytes / 1024)
|
||||
})
|
||||
}
|
||||
return t('loadVideoTrim.fileSizeMegabytes', {
|
||||
count: Number((bytes / (1024 * 1024)).toFixed(1))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
80
src/components/video/MediaUploadEmpty.stories.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaUploadEmpty from './MediaUploadEmpty.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof MediaUploadEmpty>
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Video/MediaUploadEmpty',
|
||||
component: MediaUploadEmpty,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
accept: 'video/*',
|
||||
disabled: false,
|
||||
uploading: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MediaUploadEmpty },
|
||||
setup() {
|
||||
const uploading = ref(false)
|
||||
function handleBrowse() {
|
||||
uploading.value = true
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
}, 1200)
|
||||
}
|
||||
return { args, uploading, handleBrowse }
|
||||
},
|
||||
template: `
|
||||
<MediaUploadEmpty
|
||||
v-bind="args"
|
||||
:uploading="uploading"
|
||||
@browse="handleBrowse"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Uploading: Story = {
|
||||
args: {
|
||||
uploading: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Hovered: Story = {
|
||||
render: (args) => ({
|
||||
components: { MediaUploadEmpty },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<MediaUploadEmpty
|
||||
v-bind="args"
|
||||
class="border-component-node-foreground-secondary bg-component-node-widget-background-hovered"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
188
src/components/video/MediaUploadEmpty.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MediaUploadEmpty from './MediaUploadEmpty.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
function useDropZone(
|
||||
target: { value: HTMLElement | null | undefined },
|
||||
options?:
|
||||
| {
|
||||
onDrop?: (files: File[] | null, event: DragEvent) => void
|
||||
onOver?: (files: File[] | null, event: DragEvent) => void
|
||||
onLeave?: (files: File[] | null, event: DragEvent) => void
|
||||
}
|
||||
| ((files: File[] | null, event: DragEvent) => void)
|
||||
) {
|
||||
const isOverDropZone = ref(false)
|
||||
const resolved =
|
||||
typeof options === 'function' ? { onDrop: options } : options
|
||||
|
||||
watch(
|
||||
() => target.value,
|
||||
(element, _, onCleanup) => {
|
||||
if (!element || !resolved) return
|
||||
const callbacks = resolved
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isOverDropZone.value = true
|
||||
callbacks.onOver?.(Array.from(event.dataTransfer?.files ?? []), event)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isOverDropZone.value = false
|
||||
callbacks.onDrop?.(Array.from(event.dataTransfer?.files ?? []), event)
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
isOverDropZone.value = false
|
||||
callbacks.onLeave?.(null, event)
|
||||
}
|
||||
|
||||
element.addEventListener('dragover', onDragOver)
|
||||
element.addEventListener('drop', onDrop)
|
||||
element.addEventListener('dragleave', onDragLeave)
|
||||
onCleanup(() => {
|
||||
element.removeEventListener('dragover', onDragOver)
|
||||
element.removeEventListener('drop', onDrop)
|
||||
element.removeEventListener('dragleave', onDragLeave)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { isOverDropZone }
|
||||
}
|
||||
|
||||
return { ...actual, useDropZone }
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
loadVideoTrim: {
|
||||
dragAndDropVideos: 'Drag and drop videos here to upload',
|
||||
uploadFromDevice: 'Upload from device',
|
||||
uploading: 'Uploading…'
|
||||
},
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function dragPayload(files: File[] = []) {
|
||||
return {
|
||||
dataTransfer: {
|
||||
files,
|
||||
types: ['Files'],
|
||||
items: files.map((file) => ({
|
||||
kind: 'file',
|
||||
type: file.type,
|
||||
getAsFile: () => file
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderEmpty(
|
||||
props: Partial<ComponentProps<typeof MediaUploadEmpty>> = {}
|
||||
) {
|
||||
const result = render(MediaUploadEmpty, {
|
||||
props: {
|
||||
accept: 'video/*',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return result
|
||||
}
|
||||
|
||||
async function simulateDrop(
|
||||
target: HTMLElement,
|
||||
payload: ReturnType<typeof dragPayload>
|
||||
) {
|
||||
await fireEvent.dragOver(target, payload)
|
||||
await fireEvent.drop(target, payload)
|
||||
}
|
||||
|
||||
describe('MediaUploadEmpty', () => {
|
||||
it('renders drag-drop prompt and upload button', async () => {
|
||||
await renderEmpty()
|
||||
|
||||
expect(screen.getByText('Drag and drop videos here to upload')).toBeTruthy()
|
||||
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
|
||||
expect(screen.getByText('Upload from device')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits browse when upload button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = await renderEmpty()
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits upload with video files on drop', async () => {
|
||||
const { emitted } = await renderEmpty()
|
||||
const zone = screen.getByTestId('media-upload-empty')
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' })
|
||||
|
||||
await simulateDrop(zone, dragPayload([file]))
|
||||
|
||||
expect(emitted().upload).toHaveLength(1)
|
||||
expect((emitted().upload[0] as [File[]])[0][0].name).toBe('clip.mp4')
|
||||
})
|
||||
|
||||
it('delegates drag events to provided handlers', async () => {
|
||||
const onDragOver = vi.fn(() => true)
|
||||
const onDragDrop = vi.fn(() => true)
|
||||
await renderEmpty({ onDragOver, onDragDrop })
|
||||
const zone = screen.getByTestId('media-upload-empty')
|
||||
|
||||
await simulateDrop(zone, dragPayload([]))
|
||||
|
||||
expect(onDragOver).toHaveBeenCalled()
|
||||
expect(onDragDrop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit browse when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = await renderEmpty({ disabled: true })
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows uploading spinner and hides upload controls while processing', async () => {
|
||||
await renderEmpty({
|
||||
uploading: true
|
||||
})
|
||||
|
||||
expect(screen.getByText('Uploading…')).toBeTruthy()
|
||||
expect(screen.queryByText('Drag and drop videos here to upload')).toBeNull()
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not emit browse while uploading', async () => {
|
||||
await renderEmpty({ uploading: true })
|
||||
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
})
|
||||
})
|
||||
148
src/components/video/MediaUploadEmpty.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
accept = 'video/*',
|
||||
disabled = false,
|
||||
uploading = false,
|
||||
fill = false,
|
||||
onDragOver,
|
||||
onDragDrop
|
||||
} = defineProps<{
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
uploading?: boolean
|
||||
fill?: boolean
|
||||
onDragOver?: (event: DragEvent) => boolean
|
||||
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
upload: [files: File[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const canAcceptDrop = ref(false)
|
||||
|
||||
const isInteractionDisabled = computed(() => disabled || uploading)
|
||||
|
||||
function matchesAccept(file: File) {
|
||||
if (!accept || accept === '*/*') return true
|
||||
return accept.split(',').some((pattern) => {
|
||||
const trimmed = pattern.trim()
|
||||
if (trimmed.endsWith('/*')) {
|
||||
return file.type.startsWith(trimmed.slice(0, -1))
|
||||
}
|
||||
return file.type === trimmed
|
||||
})
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop: (files, event) => {
|
||||
event?.stopPropagation()
|
||||
if (isInteractionDisabled.value) return
|
||||
|
||||
if (onDragDrop && event) {
|
||||
void Promise.resolve(onDragDrop(event)).catch(() => {})
|
||||
} else {
|
||||
const droppedFiles =
|
||||
files && files.length > 0
|
||||
? files
|
||||
: Array.from(event?.dataTransfer?.files ?? [])
|
||||
const accepted = droppedFiles.filter(matchesAccept)
|
||||
if (accepted.length) emit('upload', accepted)
|
||||
}
|
||||
canAcceptDrop.value = false
|
||||
},
|
||||
onOver: (_, event) => {
|
||||
if (isInteractionDisabled.value) {
|
||||
canAcceptDrop.value = false
|
||||
return
|
||||
}
|
||||
if (onDragOver && event) {
|
||||
canAcceptDrop.value = onDragOver(event)
|
||||
return
|
||||
}
|
||||
const items = event?.dataTransfer?.items
|
||||
canAcceptDrop.value = items
|
||||
? Array.from(items).some(
|
||||
(item) => item.kind === 'file' && matchesAcceptType(item.type)
|
||||
)
|
||||
: false
|
||||
},
|
||||
onLeave: () => {
|
||||
canAcceptDrop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function matchesAcceptType(type: string) {
|
||||
if (!accept || accept === '*/*') return true
|
||||
return accept.split(',').some((pattern) => {
|
||||
const trimmed = pattern.trim()
|
||||
if (trimmed.endsWith('/*')) {
|
||||
return type.startsWith(trimmed.slice(0, -1))
|
||||
}
|
||||
return type === trimmed
|
||||
})
|
||||
}
|
||||
|
||||
const isHovered = computed(
|
||||
() =>
|
||||
!isInteractionDisabled.value && canAcceptDrop.value && isOverDropZone.value
|
||||
)
|
||||
|
||||
function handleBrowseClick() {
|
||||
if (isInteractionDisabled.value) return
|
||||
emit('browse')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropZoneRef"
|
||||
data-testid="media-upload-empty"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-75 w-full min-w-75 flex-col items-center justify-center gap-0 rounded-lg border border-dashed border-node-component-border bg-node-component-surface px-6 py-8 transition-colors',
|
||||
fill && 'size-full flex-1',
|
||||
isHovered &&
|
||||
'border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="uploading">
|
||||
<Loader size="md" variant="loader-circle" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('loadVideoTrim.uploading') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i
|
||||
class="icon-[lucide--upload] size-8 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="max-w-48 text-center text-sm/snug text-muted-foreground">
|
||||
{{ t('loadVideoTrim.dragAndDropVideos') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
class="min-w-40"
|
||||
:disabled="disabled"
|
||||
data-testid="media-upload-browse-button"
|
||||
@click="handleBrowseClick"
|
||||
>
|
||||
{{ t('loadVideoTrim.uploadFromDevice') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
367
src/components/video/VideoFilmstripTrim.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const { activeHandle } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref: createRef } = require('vue')
|
||||
return {
|
||||
activeHandle: createRef(null) as Ref<'min' | 'max' | 'midpoint' | null>
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useRangeEditor', () => ({
|
||||
useRangeEditor: () => ({
|
||||
startDrag: vi.fn(),
|
||||
activeHandle
|
||||
})
|
||||
}))
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import VideoFilmstripTrim from './VideoFilmstripTrim.vue'
|
||||
import { timelineInsetLeftStyle } from './timelineInsetStyle'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
loadVideoTrim: {
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
loadingFilmstrip: 'Loading filmstrip…',
|
||||
adjustStartFrame: 'Adjust start frame',
|
||||
adjustEndFrame: 'Adjust end frame'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type FilmstripProps = ComponentProps<typeof VideoFilmstripTrim>
|
||||
|
||||
function expectedFrameAt(clientX: number, width = 200, frameMax = 100) {
|
||||
const contentWidth = Math.max(width - 32, 1)
|
||||
const norm = Math.min(Math.max((clientX - 16) / contentWidth, 0), 1)
|
||||
return Math.round(norm * frameMax)
|
||||
}
|
||||
|
||||
function renderFilmstrip(props: FilmstripProps) {
|
||||
return render(VideoFilmstripTrim, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mockTrackRect() {
|
||||
const track = screen.getByTestId('trim-track')
|
||||
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 64,
|
||||
right: 200,
|
||||
bottom: 64,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
return track
|
||||
}
|
||||
|
||||
describe('VideoFilmstripTrim', () => {
|
||||
beforeEach(() => {
|
||||
activeHandle.value = null
|
||||
})
|
||||
|
||||
it('insets the filmstrip track by handle width on each side', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 100,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 99,
|
||||
playheadFrame: 0,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const filmstrip = screen.getByTestId('filmstrip-track')
|
||||
expect(filmstrip.style.left).toBe('16px')
|
||||
expect(filmstrip.style.right).toBe('16px')
|
||||
})
|
||||
|
||||
it('prevents filmstrip thumbnails from being dragged', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 100,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 99,
|
||||
playheadFrame: 0,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByTestId('filmstrip-thumbnail').getAttribute('draggable')
|
||||
).toBe('false')
|
||||
})
|
||||
|
||||
it('shows whole frame number in tooltip while dragging end handle', () => {
|
||||
activeHandle.value = 'max'
|
||||
renderFilmstrip({
|
||||
totalFrames: 401,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 400,
|
||||
playheadFrame: 0,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('400')
|
||||
expect(timelineInsetLeftStyle(1).left).toBe(
|
||||
'calc(1 * (100% - 2rem) + 1rem)'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows whole frame number in tooltip while dragging start handle', () => {
|
||||
activeHandle.value = 'min'
|
||||
renderFilmstrip({
|
||||
totalFrames: 401,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 120,
|
||||
endFrame: 400,
|
||||
playheadFrame: 120,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('120')
|
||||
expect(timelineInsetLeftStyle(120 / 400).left).toBe(
|
||||
'calc(0.3 * (100% - 2rem) + 1rem)'
|
||||
)
|
||||
})
|
||||
|
||||
it('positions the playhead on the timeline', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 50,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('playhead')).toBeTruthy()
|
||||
expect(timelineInsetLeftStyle(50 / 100).left).toBe(
|
||||
'calc(0.5 * (100% - 2rem) + 1rem)'
|
||||
)
|
||||
})
|
||||
|
||||
it('scrubs to the clicked frame on the filmstrip', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
const { emitted } = render(VideoFilmstripTrim, {
|
||||
props: {
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 0,
|
||||
disabled: false,
|
||||
'onUpdate:playheadFrame': (value: number) => {
|
||||
playheadFrame.value = value
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const track = mockTrackRect()
|
||||
|
||||
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
|
||||
|
||||
expect(playheadFrame.value).toBe(expectedFrameAt(100))
|
||||
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
|
||||
})
|
||||
|
||||
it('clamps scrubbing to the trim selection when trim is enabled', async () => {
|
||||
const playheadFrame = ref(50)
|
||||
const { emitted } = render(VideoFilmstripTrim, {
|
||||
props: {
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 10,
|
||||
endFrame: 80,
|
||||
playheadFrame: 50,
|
||||
disabled: false,
|
||||
'onUpdate:playheadFrame': (value: number) => {
|
||||
playheadFrame.value = value
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const track = mockTrackRect()
|
||||
|
||||
await fireEvent.pointerDown(track, { clientX: 20, button: 0 })
|
||||
|
||||
expect(playheadFrame.value).toBe(10)
|
||||
expect(emitted().scrub).toEqual([[10]])
|
||||
|
||||
await fireEvent.pointerDown(track, { clientX: 180, button: 0 })
|
||||
|
||||
expect(playheadFrame.value).toBe(80)
|
||||
expect(emitted().scrub).toEqual([[10], [80]])
|
||||
})
|
||||
|
||||
it('updates playhead while dragging across the filmstrip', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
const { emitted } = render(VideoFilmstripTrim, {
|
||||
props: {
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 0,
|
||||
disabled: false,
|
||||
'onUpdate:playheadFrame': (value: number) => {
|
||||
playheadFrame.value = value
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const track = mockTrackRect()
|
||||
track.setPointerCapture = vi.fn()
|
||||
|
||||
await fireEvent.pointerDown(track, { clientX: 40, button: 0, pointerId: 1 })
|
||||
await fireEvent.pointerMove(track, {
|
||||
clientX: 120,
|
||||
button: 0,
|
||||
pointerId: 1
|
||||
})
|
||||
|
||||
expect(playheadFrame.value).toBe(expectedFrameAt(120))
|
||||
expect(emitted().scrub).toEqual([
|
||||
[expectedFrameAt(40)],
|
||||
[expectedFrameAt(120)]
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the frame number in a tooltip while scrubbing', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
render(VideoFilmstripTrim, {
|
||||
props: {
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 0,
|
||||
disabled: false,
|
||||
'onUpdate:playheadFrame': (value: number) => {
|
||||
playheadFrame.value = value
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const track = mockTrackRect()
|
||||
track.setPointerCapture = vi.fn()
|
||||
|
||||
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
|
||||
|
||||
await fireEvent.pointerDown(track, {
|
||||
clientX: 120,
|
||||
button: 0,
|
||||
pointerId: 1
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('scrub-tooltip')).toHaveTextContent(
|
||||
String(expectedFrameAt(120))
|
||||
)
|
||||
|
||||
await fireEvent.pointerUp(track, { pointerId: 1 })
|
||||
|
||||
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders trim handles when enabled', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 100,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 10,
|
||||
endFrame: 80,
|
||||
playheadFrame: 10,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('handle-start')).toBeTruthy()
|
||||
expect(screen.getByTestId('handle-end')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides trim handles when disabled', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 100,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 10,
|
||||
endFrame: 80,
|
||||
playheadFrame: 10,
|
||||
disabled: true
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('handle-start')).toBeNull()
|
||||
expect(screen.queryByTestId('handle-end')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides trim selection UI when trim is toggled off', () => {
|
||||
renderFilmstrip({
|
||||
totalFrames: 100,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 10,
|
||||
endFrame: 80,
|
||||
playheadFrame: 10,
|
||||
trimEnabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('playhead')).toBeTruthy()
|
||||
expect(screen.getByTestId('filmstrip-track').style.left).toBe('16px')
|
||||
expect(screen.getByTestId('filmstrip-track').style.right).toBe('16px')
|
||||
expect(screen.queryByTestId('handle-start')).toBeNull()
|
||||
expect(screen.queryByTestId('handle-end')).toBeNull()
|
||||
})
|
||||
|
||||
it('scrubs across the full timeline when trim is toggled off', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
const { emitted } = render(VideoFilmstripTrim, {
|
||||
props: {
|
||||
totalFrames: 101,
|
||||
thumbnails: ['data:image/jpeg;base64,one'],
|
||||
startFrame: 10,
|
||||
endFrame: 80,
|
||||
playheadFrame: 0,
|
||||
trimEnabled: false,
|
||||
'onUpdate:playheadFrame': (value: number) => {
|
||||
playheadFrame.value = value
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const track = mockTrackRect()
|
||||
|
||||
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
|
||||
|
||||
expect(playheadFrame.value).toBe(expectedFrameAt(100))
|
||||
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
|
||||
})
|
||||
})
|
||||
365
src/components/video/VideoFilmstripTrim.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="flex h-16 w-full items-stretch gap-px" @pointerdown.stop>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-14 shrink-0 items-center justify-center rounded-l-lg border-none bg-component-node-widget-background px-4 text-muted-foreground',
|
||||
!disabled &&
|
||||
'cursor-pointer hover:bg-component-node-widget-background-hovered',
|
||||
disabled && 'cursor-default opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="disabled"
|
||||
:aria-label="
|
||||
isPlaying ? t('loadVideoTrim.pause') : t('loadVideoTrim.play')
|
||||
"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]',
|
||||
!isPlaying && 'ml-0.5',
|
||||
'size-5'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref="trackRef"
|
||||
data-testid="trim-track"
|
||||
:class="
|
||||
cn(
|
||||
'relative min-w-0 flex-1 rounded-r-lg bg-component-node-widget-background',
|
||||
isDraggingTimeline ? 'cursor-ew-resize' : 'cursor-default'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="startScrubDrag"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<div
|
||||
v-if="isScrubDragging"
|
||||
data-testid="scrub-tooltip"
|
||||
class="pointer-events-none absolute bottom-full z-30 mb-1 flex -translate-x-1/2 flex-col items-center"
|
||||
:style="playheadStyle"
|
||||
>
|
||||
<span
|
||||
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ playheadFrame }}
|
||||
</span>
|
||||
<span
|
||||
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trimEnabled && (activeHandle === 'min' || activeHandle === 'max')"
|
||||
data-testid="trim-handle-tooltip"
|
||||
class="pointer-events-none absolute bottom-full z-10 mb-1 flex -translate-x-1/2 flex-col items-center"
|
||||
:style="activeHandleTooltipStyle"
|
||||
>
|
||||
<span
|
||||
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ activeHandleFrame }}
|
||||
</span>
|
||||
<span
|
||||
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="filmstrip-track"
|
||||
class="pointer-events-none absolute top-2 flex h-12 items-stretch overflow-hidden"
|
||||
:style="{
|
||||
left: `${HANDLE_WIDTH_PX}px`,
|
||||
right: `${HANDLE_WIDTH_PX}px`
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
v-for="(thumbnail, index) in thumbnails"
|
||||
:key="index"
|
||||
data-testid="filmstrip-thumbnail"
|
||||
:src="thumbnail"
|
||||
alt=""
|
||||
draggable="false"
|
||||
class="h-full w-auto shrink-0 select-none"
|
||||
/>
|
||||
<div
|
||||
v-if="isFilmstripLoading"
|
||||
class="flex size-full items-stretch gap-px overflow-hidden"
|
||||
data-testid="filmstrip-skeleton"
|
||||
:aria-busy="true"
|
||||
:aria-label="t('loadVideoTrim.loadingFilmstrip')"
|
||||
>
|
||||
<Skeleton
|
||||
v-for="index in FILMSTRIP_SAMPLE_COUNT"
|
||||
:key="index"
|
||||
class="h-full min-w-10 flex-1 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trimEnabled && startNorm > 0"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 bg-black/50"
|
||||
:style="leftDimStyle"
|
||||
/>
|
||||
<div
|
||||
v-if="trimEnabled && endNorm < 1"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 bg-black/50"
|
||||
:style="rightDimStyle"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="trimEnabled"
|
||||
class="pointer-events-none absolute inset-y-0 flex"
|
||||
:style="selectionStyle"
|
||||
>
|
||||
<button
|
||||
v-if="!disabled && totalFrames > 1"
|
||||
type="button"
|
||||
data-testid="handle-start"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
|
||||
'items-center justify-center bg-video-trim-selection-background',
|
||||
'rounded-l-lg border-none p-0'
|
||||
)
|
||||
"
|
||||
:aria-label="t('loadVideoTrim.adjustStartFrame')"
|
||||
@pointerdown.stop="startDrag('min', $event)"
|
||||
>
|
||||
<span class="h-4 w-px rounded-full bg-secondary-background" />
|
||||
</button>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
|
||||
<div class="h-12 shrink-0" />
|
||||
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!disabled && totalFrames > 1"
|
||||
type="button"
|
||||
data-testid="handle-end"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
|
||||
'items-center justify-center bg-video-trim-selection-background',
|
||||
'rounded-r-lg border-none p-0'
|
||||
)
|
||||
"
|
||||
:aria-label="t('loadVideoTrim.adjustEndFrame')"
|
||||
@pointerdown.stop="startDrag('max', $event)"
|
||||
>
|
||||
<span class="h-4 w-px rounded-full bg-secondary-background" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="playhead"
|
||||
class="absolute top-2 z-20 flex h-12 w-3 -translate-x-1/2 cursor-ew-resize touch-none items-stretch justify-center"
|
||||
:style="playheadStyle"
|
||||
@pointerdown.stop="startScrubDrag"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none w-0.5 bg-video-trim-playhead-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, toRef, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { timelineInsetLeftStyle } from '@/components/video/timelineInsetStyle'
|
||||
import { FILMSTRIP_SAMPLE_COUNT } from '@/composables/video/useVideoFilmstrip'
|
||||
import { useRangeEditor } from '@/composables/useRangeEditor'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { denormalize } from '@/utils/mathUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const HANDLE_WIDTH_PX = 16
|
||||
|
||||
const {
|
||||
totalFrames,
|
||||
thumbnails,
|
||||
disabled = false,
|
||||
trimEnabled = true
|
||||
} = defineProps<{
|
||||
totalFrames: number
|
||||
thumbnails: string[]
|
||||
disabled?: boolean
|
||||
trimEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const startFrame = defineModel<number>('startFrame', { required: true })
|
||||
const endFrame = defineModel<number>('endFrame', { required: true })
|
||||
const playheadFrame = defineModel<number>('playheadFrame', { required: true })
|
||||
const isPlaying = defineModel<boolean>('isPlaying', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
scrub: [frame: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
|
||||
const isScrubDragging = ref(false)
|
||||
const frameMax = computed(() => Math.max(totalFrames - 1, 0))
|
||||
|
||||
const rangeValue = computed<RangeValue>({
|
||||
get: () => ({
|
||||
min: startFrame.value,
|
||||
max: endFrame.value
|
||||
}),
|
||||
set: (value) => {
|
||||
startFrame.value = Math.round(value.min)
|
||||
endFrame.value = Math.round(value.max)
|
||||
}
|
||||
})
|
||||
|
||||
const contentInsetX = computed(() => HANDLE_WIDTH_PX)
|
||||
|
||||
const { startDrag, activeHandle } = useRangeEditor({
|
||||
trackRef,
|
||||
modelValue: rangeValue,
|
||||
valueMin: toRef(() => 0),
|
||||
valueMax: frameMax,
|
||||
showMidpoint: toRef(() => false),
|
||||
contentInsetX
|
||||
})
|
||||
|
||||
const isDraggingTimeline = computed(
|
||||
() => isScrubDragging.value || activeHandle.value !== null
|
||||
)
|
||||
|
||||
const isFilmstripLoading = computed(() => thumbnails.length === 0)
|
||||
|
||||
const trimSelectionBarClass = computed(() =>
|
||||
isFilmstripLoading.value
|
||||
? 'bg-component-node-widget-background'
|
||||
: 'bg-video-trim-selection-background'
|
||||
)
|
||||
|
||||
function pointerToFrame(event: PointerEvent) {
|
||||
const el = trackRef.value
|
||||
if (!el) return playheadFrame.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
const inset = HANDLE_WIDTH_PX
|
||||
const contentWidth = Math.max(rect.width - 2 * inset, 1)
|
||||
const normalized = clamp(
|
||||
(event.clientX - rect.left - inset) / contentWidth,
|
||||
0,
|
||||
1
|
||||
)
|
||||
return Math.round(denormalize(normalized, 0, frameMax.value))
|
||||
}
|
||||
|
||||
const scrubFrameMin = computed(() => (trimEnabled ? startFrame.value : 0))
|
||||
const scrubFrameMax = computed(() =>
|
||||
trimEnabled ? endFrame.value : frameMax.value
|
||||
)
|
||||
|
||||
function scrubToFrame(frame: number) {
|
||||
const clamped = clamp(frame, scrubFrameMin.value, scrubFrameMax.value)
|
||||
playheadFrame.value = clamped
|
||||
emit('scrub', clamped)
|
||||
}
|
||||
|
||||
function updateScrubFromPointer(event: PointerEvent) {
|
||||
const frame = pointerToFrame(event)
|
||||
if (frame === playheadFrame.value) return
|
||||
scrubToFrame(frame)
|
||||
}
|
||||
|
||||
let cleanupScrubDrag: (() => void) | null = null
|
||||
|
||||
function startScrubDrag(event: PointerEvent) {
|
||||
if (disabled || totalFrames <= 1 || event.button !== 0) return
|
||||
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
|
||||
cleanupScrubDrag?.()
|
||||
|
||||
isScrubDragging.value = true
|
||||
scrubToFrame(pointerToFrame(event))
|
||||
el.setPointerCapture(event.pointerId)
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
updateScrubFromPointer(moveEvent)
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
isScrubDragging.value = false
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', endDrag)
|
||||
el.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupScrubDrag = null
|
||||
}
|
||||
|
||||
cleanupScrubDrag = endDrag
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', endDrag)
|
||||
el.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isScrubDragging.value = false
|
||||
cleanupScrubDrag?.()
|
||||
})
|
||||
|
||||
const startNorm = computed(() =>
|
||||
frameMax.value <= 0 ? 0 : startFrame.value / frameMax.value
|
||||
)
|
||||
const endNorm = computed(() =>
|
||||
frameMax.value <= 0 ? 1 : endFrame.value / frameMax.value
|
||||
)
|
||||
|
||||
const playheadNorm = computed(() =>
|
||||
frameMax.value <= 0 ? 0 : playheadFrame.value / frameMax.value
|
||||
)
|
||||
|
||||
const playheadStyle = computed(() => timelineInsetLeftStyle(playheadNorm.value))
|
||||
|
||||
const leftDimStyle = computed(() => ({
|
||||
width: `calc(${startNorm.value} * (100% - 2rem))`
|
||||
}))
|
||||
|
||||
const rightDimStyle = computed(() => ({
|
||||
width: `calc(${1 - endNorm.value} * (100% - 2rem))`
|
||||
}))
|
||||
|
||||
const selectionStyle = computed(() => ({
|
||||
left: `calc(${startNorm.value} * (100% - 2rem))`,
|
||||
width: `calc((${endNorm.value} - ${startNorm.value}) * (100% - 2rem) + 2rem)`
|
||||
}))
|
||||
|
||||
const activeHandleFrame = computed(() => {
|
||||
if (activeHandle.value === 'min') return startFrame.value
|
||||
if (activeHandle.value === 'max') return endFrame.value
|
||||
return 0
|
||||
})
|
||||
|
||||
const activeHandleTooltipStyle = computed(() => {
|
||||
const norm = activeHandle.value === 'min' ? startNorm.value : endNorm.value
|
||||
return timelineInsetLeftStyle(norm)
|
||||
})
|
||||
|
||||
function togglePlay() {
|
||||
if (disabled) return
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
</script>
|
||||
5
src/components/video/timelineInsetStyle.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function timelineInsetLeftStyle(normalized: number) {
|
||||
return {
|
||||
left: `calc(${normalized} * (100% - 2rem) + 1rem)`
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
|
||||
): ComfyNodeDefImpl | undefined {
|
||||
const name = tile.nodeName
|
||||
if (!name) return undefined
|
||||
const byName = nodeDefStore.allNodeDefsByName[name]
|
||||
if (byName) return byName
|
||||
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
|
||||
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
: name
|
||||
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
|
||||
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
|
||||
return nodeDefStore.allNodeDefsByName[name]
|
||||
|
||||
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
|
||||
}
|
||||
|
||||
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const CHANNEL_INDEX: Record<ChannelMode, number> = {
|
||||
luminance: 5
|
||||
}
|
||||
|
||||
export interface PixelReadout {
|
||||
interface PixelReadout {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export type ResizeDirection =
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -18,7 +18,7 @@ export type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
|
||||
@@ -50,6 +50,7 @@ interface HarnessOptions {
|
||||
valueMax?: number
|
||||
showMidpoint?: boolean
|
||||
track?: HTMLElement | null
|
||||
contentInsetX?: number
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -72,6 +73,7 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
|
||||
const valueMin = ref(opts.valueMin ?? 0)
|
||||
const valueMax = ref(opts.valueMax ?? 100)
|
||||
const showMidpoint = ref(opts.showMidpoint ?? true)
|
||||
const contentInsetX = ref(opts.contentInsetX ?? 0)
|
||||
|
||||
let api: ReturnType<typeof useRangeEditor> | undefined
|
||||
const TestComponent = defineComponent({
|
||||
@@ -81,7 +83,8 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax,
|
||||
showMidpoint
|
||||
showMidpoint,
|
||||
contentInsetX
|
||||
})
|
||||
return () => null
|
||||
}
|
||||
@@ -323,4 +326,44 @@ describe('useRangeEditor', () => {
|
||||
expect.arrayContaining(['pointermove', 'pointerup', 'lostpointercapture'])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps pointer at content inset to valueMin when contentInsetX is set', () => {
|
||||
harness = mountRangeEditor({
|
||||
initial: { min: 20, max: 80, midpoint: 0.5 },
|
||||
valueMin: 0,
|
||||
valueMax: 100,
|
||||
showMidpoint: false,
|
||||
contentInsetX: 16
|
||||
})
|
||||
|
||||
harness.api.startDrag(
|
||||
'min',
|
||||
createPointerEvent('pointerdown', { clientX: 16 })
|
||||
)
|
||||
harness.trackRef.value!.dispatchEvent(
|
||||
createPointerEvent('pointermove', { clientX: 16 })
|
||||
)
|
||||
|
||||
expect(harness.modelValue.value.min).toBe(0)
|
||||
})
|
||||
|
||||
it('maps pointer at right content inset to valueMax when contentInsetX is set', () => {
|
||||
harness = mountRangeEditor({
|
||||
initial: { min: 0, max: 80, midpoint: 0.5 },
|
||||
valueMin: 0,
|
||||
valueMax: 100,
|
||||
showMidpoint: false,
|
||||
contentInsetX: 16
|
||||
})
|
||||
|
||||
harness.api.startDrag(
|
||||
'max',
|
||||
createPointerEvent('pointerdown', { clientX: 184 })
|
||||
)
|
||||
harness.trackRef.value!.dispatchEvent(
|
||||
createPointerEvent('pointermove', { clientX: 184 })
|
||||
)
|
||||
|
||||
expect(harness.modelValue.value.max).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseRangeEditorOptions {
|
||||
valueMin: Ref<number>
|
||||
valueMax: Ref<number>
|
||||
showMidpoint: Ref<boolean>
|
||||
contentInsetX?: Ref<number>
|
||||
}
|
||||
|
||||
export function useRangeEditor({
|
||||
@@ -21,7 +22,8 @@ export function useRangeEditor({
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax,
|
||||
showMidpoint
|
||||
showMidpoint,
|
||||
contentInsetX
|
||||
}: UseRangeEditorOptions) {
|
||||
const activeHandle = ref<HandleType | null>(null)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
@@ -30,7 +32,13 @@ export function useRangeEditor({
|
||||
const el = trackRef.value
|
||||
if (!el) return valueMin.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||
const inset = contentInsetX?.value ?? 0
|
||||
const contentWidth = Math.max(rect.width - 2 * inset, 1)
|
||||
const normalized = clamp(
|
||||
(e.clientX - rect.left - inset) / contentWidth,
|
||||
0,
|
||||
1
|
||||
)
|
||||
return denormalize(normalized, valueMin.value, valueMax.value)
|
||||
}
|
||||
|
||||
@@ -108,6 +116,7 @@ export function useRangeEditor({
|
||||
|
||||
return {
|
||||
handleTrackPointerDown,
|
||||
startDrag
|
||||
startDrag,
|
||||
activeHandle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
|
||||
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
|
||||
|
||||
export type RunButtonTelemetryOptions = {
|
||||
type RunButtonTelemetryOptions = {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
75
src/composables/video/probeVideoFrameRate.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseMp4AverageFrameRate } from './probeVideoFrameRate'
|
||||
|
||||
function writeUint32(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(4)
|
||||
new DataView(bytes.buffer).setUint32(0, value)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function writeBox(type: string, content: Uint8Array): Uint8Array {
|
||||
const box = new Uint8Array(8 + content.length)
|
||||
box.set(writeUint32(8 + content.length), 0)
|
||||
for (let index = 0; index < 4; index++) {
|
||||
box[4 + index] = type.charCodeAt(index)
|
||||
}
|
||||
box.set(content, 8)
|
||||
return box
|
||||
}
|
||||
|
||||
function concatBoxes(...boxes: Uint8Array[]): Uint8Array {
|
||||
const totalLength = boxes.reduce((sum, box) => sum + box.length, 0)
|
||||
const merged = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const box of boxes) {
|
||||
merged.set(box, offset)
|
||||
offset += box.length
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function createVideoTrackBox(
|
||||
sampleCount: number,
|
||||
timescale: number
|
||||
): Uint8Array {
|
||||
const handler = writeBox(
|
||||
'hdlr',
|
||||
concatBoxes(
|
||||
writeUint32(0),
|
||||
writeUint32(0),
|
||||
new Uint8Array([0x76, 0x69, 0x64, 0x65])
|
||||
)
|
||||
)
|
||||
const mediaHeader = writeBox(
|
||||
'mdhd',
|
||||
concatBoxes(
|
||||
writeUint32(0),
|
||||
writeUint32(0),
|
||||
writeUint32(0),
|
||||
writeUint32(timescale),
|
||||
writeUint32(timescale * 10)
|
||||
)
|
||||
)
|
||||
const sampleSizes = writeBox(
|
||||
'stsz',
|
||||
concatBoxes(writeUint32(0), writeUint32(0), writeUint32(sampleCount))
|
||||
)
|
||||
const media = writeBox('mdia', concatBoxes(mediaHeader, sampleSizes, handler))
|
||||
return writeBox('trak', concatBoxes(media))
|
||||
}
|
||||
|
||||
describe('parseMp4AverageFrameRate', () => {
|
||||
it('derives average frame rate from video track sample count and duration', () => {
|
||||
const moov = writeBox('moov', createVideoTrackBox(240, 24))
|
||||
const data = concatBoxes(moov)
|
||||
|
||||
expect(parseMp4AverageFrameRate(data, 10)).toBe(24)
|
||||
})
|
||||
|
||||
it('returns undefined when moov metadata is missing', () => {
|
||||
expect(parseMp4AverageFrameRate(new Uint8Array([0, 0, 0, 0]), 10)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
198
src/composables/video/probeVideoFrameRate.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
|
||||
|
||||
const PROBE_CHUNK_BYTES = 512 * 1024
|
||||
const MAX_FRAME_RATE = 240
|
||||
|
||||
interface BoxRange {
|
||||
type: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function readUint32(data: Uint8Array, offset: number): number {
|
||||
if (offset + 4 > data.length) return 0
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
return view.getUint32(offset)
|
||||
}
|
||||
|
||||
function readBoxType(data: Uint8Array, offset: number): string {
|
||||
if (offset + 4 > data.length) return ''
|
||||
return String.fromCharCode(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3]
|
||||
)
|
||||
}
|
||||
|
||||
function* iterateBoxes(
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): Generator<BoxRange> {
|
||||
let pos = start
|
||||
|
||||
while (pos + 8 <= end) {
|
||||
let size = readUint32(data, pos)
|
||||
const type = readBoxType(data, pos + 4)
|
||||
let headerSize = 8
|
||||
|
||||
if (size === 1) {
|
||||
if (pos + 16 > end) return
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
size = Number(view.getBigUint64(pos + 8))
|
||||
headerSize = 16
|
||||
}
|
||||
|
||||
if (size < headerSize) return
|
||||
|
||||
const boxEnd = pos + size
|
||||
if (boxEnd > end) return
|
||||
|
||||
yield { type, start: pos + headerSize, end: boxEnd }
|
||||
pos = boxEnd
|
||||
}
|
||||
}
|
||||
|
||||
function findBox(
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
end: number,
|
||||
type: string
|
||||
): BoxRange | undefined {
|
||||
for (const box of iterateBoxes(data, start, end)) {
|
||||
if (box.type === type) return box
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findBoxDeep(
|
||||
data: Uint8Array,
|
||||
root: BoxRange,
|
||||
type: string
|
||||
): BoxRange | undefined {
|
||||
const direct = findBox(data, root.start, root.end, type)
|
||||
if (direct) return direct
|
||||
|
||||
for (const child of iterateBoxes(data, root.start, root.end)) {
|
||||
const nested = findBoxDeep(data, child, type)
|
||||
if (nested) return nested
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isVideoTrack(data: Uint8Array, trak: BoxRange): boolean {
|
||||
const handler = findBoxDeep(data, trak, 'hdlr')
|
||||
if (!handler || handler.start + 12 > handler.end) return false
|
||||
return readBoxType(data, handler.start + 8) === 'vide'
|
||||
}
|
||||
|
||||
function readUint64(data: Uint8Array, offset: number): number {
|
||||
if (offset + 8 > data.length) return 0
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
return Number(view.getBigUint64(offset))
|
||||
}
|
||||
|
||||
function frameRateFromTrack(
|
||||
data: Uint8Array,
|
||||
trak: BoxRange,
|
||||
durationSeconds: number
|
||||
): number | undefined {
|
||||
const mediaHeader = findBoxDeep(data, trak, 'mdhd')
|
||||
const sampleSizes = findBoxDeep(data, trak, 'stsz')
|
||||
if (!mediaHeader || !sampleSizes) return undefined
|
||||
|
||||
const version = data[mediaHeader.start]
|
||||
let timescale: number
|
||||
let mediaDurationTicks: number
|
||||
|
||||
if (version === 1) {
|
||||
timescale = readUint32(data, mediaHeader.start + 20)
|
||||
mediaDurationTicks = readUint64(data, mediaHeader.start + 24)
|
||||
} else {
|
||||
timescale = readUint32(data, mediaHeader.start + 12)
|
||||
mediaDurationTicks = readUint32(data, mediaHeader.start + 16)
|
||||
}
|
||||
|
||||
const sampleCount = readUint32(data, sampleSizes.start + 8)
|
||||
|
||||
if (timescale <= 0 || sampleCount <= 0) return undefined
|
||||
|
||||
const trackDurationSeconds =
|
||||
mediaDurationTicks > 0 ? mediaDurationTicks / timescale : durationSeconds
|
||||
const duration =
|
||||
trackDurationSeconds > 0 ? trackDurationSeconds : durationSeconds
|
||||
if (duration <= 0) return undefined
|
||||
|
||||
const frameRate = sampleCount / duration
|
||||
if (frameRate <= 0 || frameRate > MAX_FRAME_RATE) return undefined
|
||||
|
||||
return frameRate
|
||||
}
|
||||
|
||||
export function parseMp4AverageFrameRate(
|
||||
data: Uint8Array,
|
||||
durationSeconds: number
|
||||
): number | undefined {
|
||||
if (durationSeconds <= 0) return undefined
|
||||
|
||||
const movie = findBox(data, 0, data.length, 'moov')
|
||||
if (!movie) return undefined
|
||||
|
||||
for (const track of iterateBoxes(data, movie.start, movie.end)) {
|
||||
if (track.type !== 'trak' || !isVideoTrack(data, track)) continue
|
||||
|
||||
const frameRate = frameRateFromTrack(data, track, durationSeconds)
|
||||
if (frameRate != null) return frameRate
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function fetchRange(
|
||||
url: string,
|
||||
start: number,
|
||||
end: number
|
||||
): Promise<ArrayBuffer | undefined> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Range: `bytes=${start}-${end}` }
|
||||
})
|
||||
if (response.status !== 206) return undefined
|
||||
return await response.arrayBuffer()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeVideoFrameRate(
|
||||
url: string,
|
||||
durationSeconds: number,
|
||||
byteSize?: number
|
||||
): Promise<number | undefined> {
|
||||
if (durationSeconds <= 0) return undefined
|
||||
|
||||
const resolvedByteSize = byteSize ?? (await fetchHttpResourceByteSize(url))
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
const leading = await fetchRange(url, 0, PROBE_CHUNK_BYTES - 1)
|
||||
if (leading) chunks.push(new Uint8Array(leading))
|
||||
|
||||
if (resolvedByteSize != null && resolvedByteSize > PROBE_CHUNK_BYTES) {
|
||||
const trailingStart = Math.max(0, resolvedByteSize - PROBE_CHUNK_BYTES)
|
||||
const trailing = await fetchRange(
|
||||
url,
|
||||
trailingStart,
|
||||
Math.max(trailingStart, resolvedByteSize - 1)
|
||||
)
|
||||
if (trailing) chunks.push(new Uint8Array(trailing))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const frameRate = parseMp4AverageFrameRate(chunk, durationSeconds)
|
||||
if (frameRate != null) return frameRate
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
91
src/composables/video/useLoadVideoPreview.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { computed } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
useLoadVideoPreview,
|
||||
nodeHasLoadVideoPreview
|
||||
} from './useLoadVideoPreview'
|
||||
|
||||
const { getNodeImageUrlsMock } = vi.hoisted(() => ({
|
||||
getNodeImageUrlsMock: vi.fn<(node: unknown) => string[] | undefined>(
|
||||
() => undefined
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs: {},
|
||||
getNodeImageUrls: getNodeImageUrlsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: () => ''
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `https://example.test${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useLoadVideoPreview', () => {
|
||||
it('falls back to the file widget value when node outputs are unavailable', () => {
|
||||
getNodeImageUrlsMock.mockReturnValue(undefined)
|
||||
|
||||
const node = computed(() => ({
|
||||
widgets: [{ name: 'file', value: 'clip.mp4' }]
|
||||
}))
|
||||
|
||||
const { videoUrl } = useLoadVideoPreview(node as never)
|
||||
|
||||
expect(videoUrl.value).toBe(
|
||||
'https://example.test/view?filename=clip.mp4&subfolder=&type=input'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers node output preview urls over the file widget fallback', () => {
|
||||
getNodeImageUrlsMock.mockReturnValue([
|
||||
'https://example.test/view?filename=from-output.mp4'
|
||||
])
|
||||
|
||||
const node = computed(() => ({
|
||||
widgets: [{ name: 'file', value: 'clip.mp4' }]
|
||||
}))
|
||||
|
||||
const { videoUrl } = useLoadVideoPreview(node as never)
|
||||
|
||||
expect(videoUrl.value).toBe(
|
||||
'https://example.test/view?filename=from-output.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects preview availability from the file widget fallback', () => {
|
||||
getNodeImageUrlsMock.mockReturnValue(undefined)
|
||||
|
||||
expect(
|
||||
nodeHasLoadVideoPreview({
|
||||
widgets: [{ name: 'file', value: 'clip.mp4' }]
|
||||
} as never)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores remote widget placeholder values', () => {
|
||||
getNodeImageUrlsMock.mockReturnValue(undefined)
|
||||
|
||||
const node = computed(() => ({
|
||||
widgets: [{ name: 'file', value: 'Loading...' }]
|
||||
}))
|
||||
|
||||
const { videoUrl } = useLoadVideoPreview(node as never)
|
||||
|
||||
expect(videoUrl.value).toBeUndefined()
|
||||
expect(
|
||||
nodeHasLoadVideoPreview({
|
||||
widgets: [{ name: 'file', value: 'Loading...' }]
|
||||
} as never)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
75
src/composables/video/useLoadVideoPreview.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
const REMOTE_WIDGET_PLACEHOLDER = 'Loading...'
|
||||
|
||||
function isResolvableFileWidgetValue(raw: unknown): raw is string {
|
||||
if (typeof raw !== 'string' || !raw || raw === REMOTE_WIDGET_PLACEHOLDER) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { filename } = parseImageWidgetValue(raw)
|
||||
return Boolean(filename)
|
||||
}
|
||||
|
||||
function resolveVideoUrlFromFileWidget(node: LGraphNode): string | undefined {
|
||||
const fileWidget = node.widgets?.find((widget) => widget.name === 'file')
|
||||
const raw = fileWidget?.value
|
||||
if (!isResolvableFileWidgetValue(raw)) return undefined
|
||||
|
||||
const { filename, subfolder, type } = parseImageWidgetValue(raw)
|
||||
if (!filename) return undefined
|
||||
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
export function nodeHasLoadVideoPreview(
|
||||
node: LGraphNode | null | undefined
|
||||
): boolean {
|
||||
if (!node) return false
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
if ((nodeOutputStore.getNodeImageUrls(node)?.length ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return resolveVideoUrlFromFileWidget(node) !== undefined
|
||||
}
|
||||
|
||||
export function useLoadVideoPreview(
|
||||
node: ComputedRef<LGraphNode | null | undefined>
|
||||
) {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const videoUrl = computed(() => {
|
||||
const currentNode = node.value
|
||||
if (!currentNode) return undefined
|
||||
|
||||
void nodeOutputStore.nodeOutputs
|
||||
|
||||
const graphId = currentNode.graph?.rootGraph?.id
|
||||
if (graphId) {
|
||||
void widgetValueStore.getWidget(widgetId(graphId, currentNode.id, 'file'))
|
||||
?.value
|
||||
}
|
||||
|
||||
return (
|
||||
nodeOutputStore.getNodeImageUrls(currentNode)?.[0] ??
|
||||
resolveVideoUrlFromFileWidget(currentNode)
|
||||
)
|
||||
})
|
||||
|
||||
return { videoUrl }
|
||||
}
|
||||
198
src/composables/video/useVideoFilmstrip.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
|
||||
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
|
||||
|
||||
import {
|
||||
DEFAULT_VIDEO_FPS,
|
||||
FILMSTRIP_SAMPLE_COUNT,
|
||||
useVideoFilmstrip
|
||||
} from './useVideoFilmstrip'
|
||||
|
||||
vi.mock('@/composables/video/probeVideoFrameRate', () => ({
|
||||
probeVideoFrameRate: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/httpResourceByteSize', () => ({
|
||||
fetchHttpResourceByteSize: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
type VideoListener = (event: Event) => void
|
||||
|
||||
class MockVideoElement {
|
||||
preload = ''
|
||||
muted = false
|
||||
playsInline = false
|
||||
crossOrigin = ''
|
||||
duration = 10
|
||||
videoWidth = 512
|
||||
videoHeight = 512
|
||||
src = ''
|
||||
private listeners = new Map<string, Set<VideoListener>>()
|
||||
|
||||
set currentTime(_value: number) {
|
||||
queueMicrotask(() => this.emit('seeked'))
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: VideoListener, options?: boolean) {
|
||||
if (options === true) {
|
||||
const wrapped = (event: Event) => {
|
||||
this.removeEventListener(type, wrapped)
|
||||
listener(event)
|
||||
}
|
||||
this.getListeners(type).add(wrapped)
|
||||
return
|
||||
}
|
||||
this.getListeners(type).add(listener)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: VideoListener) {
|
||||
this.getListeners(type).delete(listener)
|
||||
}
|
||||
|
||||
load() {
|
||||
this.src = ''
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
if (name === 'src') this.src = ''
|
||||
}
|
||||
|
||||
private getListeners(type: string) {
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set())
|
||||
}
|
||||
return this.listeners.get(type)!
|
||||
}
|
||||
|
||||
emit(type: string) {
|
||||
for (const listener of [...this.getListeners(type)]) {
|
||||
listener(new Event(type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
drawImage: vi.fn()
|
||||
}),
|
||||
toDataURL: () => 'data:image/jpeg;base64,thumb'
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function installVideoMocks() {
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName === 'video') {
|
||||
const video = new MockVideoElement()
|
||||
queueMicrotask(() => video.emit('loadedmetadata'))
|
||||
return video as unknown as HTMLVideoElement
|
||||
}
|
||||
if (tagName === 'canvas') {
|
||||
return createMockCanvas()
|
||||
}
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useVideoFilmstrip', () => {
|
||||
let scope: EffectScope | undefined
|
||||
|
||||
function runWithScope<T>(fn: () => T): T {
|
||||
scope = effectScope()
|
||||
return scope.run(fn)!
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('estimates total frames from duration and default fps', async () => {
|
||||
installVideoMocks()
|
||||
|
||||
const videoUrl = ref('https://example.com/video.mp4')
|
||||
const { totalFrames, duration, loading } = runWithScope(() =>
|
||||
useVideoFilmstrip(videoUrl)
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(loading.value).toBe(false))
|
||||
|
||||
expect(duration.value).toBe(10)
|
||||
expect(totalFrames.value).toBe(Math.round(10 * DEFAULT_VIDEO_FPS))
|
||||
})
|
||||
|
||||
it('clears state when url is removed', async () => {
|
||||
installVideoMocks()
|
||||
|
||||
const videoUrl = ref<string | undefined>('https://example.com/video.mp4')
|
||||
const { thumbnails, totalFrames, loading } = runWithScope(() =>
|
||||
useVideoFilmstrip(videoUrl)
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(loading.value).toBe(false))
|
||||
|
||||
videoUrl.value = undefined
|
||||
await nextTick()
|
||||
|
||||
expect(thumbnails.value).toEqual([])
|
||||
expect(totalFrames.value).toBe(0)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses probed frame rate and file size when available', async () => {
|
||||
installVideoMocks()
|
||||
vi.mocked(probeVideoFrameRate).mockResolvedValueOnce(24)
|
||||
vi.mocked(fetchHttpResourceByteSize).mockResolvedValueOnce(5 * 1024 * 1024)
|
||||
|
||||
const videoUrl = ref('https://example.com/video.mp4')
|
||||
const { totalFrames, fps, fileSize, loading } = runWithScope(() =>
|
||||
useVideoFilmstrip(videoUrl)
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(loading.value).toBe(false))
|
||||
|
||||
expect(fps.value).toBe(24)
|
||||
expect(totalFrames.value).toBe(240)
|
||||
expect(fileSize.value).toBe(5 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('samples the configured number of frames', async () => {
|
||||
let seekCount = 0
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName === 'video') {
|
||||
const video = new MockVideoElement()
|
||||
video.addEventListener('seeked', () => {
|
||||
seekCount += 1
|
||||
})
|
||||
queueMicrotask(() => video.emit('loadedmetadata'))
|
||||
return video as unknown as HTMLVideoElement
|
||||
}
|
||||
if (tagName === 'canvas') {
|
||||
return createMockCanvas()
|
||||
}
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
|
||||
const videoUrl = ref('https://example.com/video.mp4')
|
||||
const { thumbnails, loading } = runWithScope(() =>
|
||||
useVideoFilmstrip(videoUrl, {
|
||||
sampleCount: FILMSTRIP_SAMPLE_COUNT
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(loading.value).toBe(false))
|
||||
|
||||
expect(seekCount).toBe(FILMSTRIP_SAMPLE_COUNT)
|
||||
expect(thumbnails.value).toHaveLength(FILMSTRIP_SAMPLE_COUNT)
|
||||
})
|
||||
})
|
||||
206
src/composables/video/useVideoFilmstrip.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
|
||||
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
|
||||
|
||||
export const DEFAULT_VIDEO_FPS = 20
|
||||
export const FILMSTRIP_SAMPLE_COUNT = 20
|
||||
|
||||
interface UseVideoFilmstripOptions {
|
||||
fps?: number
|
||||
sampleCount?: number
|
||||
}
|
||||
|
||||
function waitForEvent(target: EventTarget, eventName: string): Promise<Event> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSuccess = (event: Event) => {
|
||||
cleanup()
|
||||
resolve(event)
|
||||
}
|
||||
const onError = () => {
|
||||
cleanup()
|
||||
reject(new Error(`Failed to load ${eventName}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
target.removeEventListener(eventName, onSuccess)
|
||||
target.removeEventListener('error', onError)
|
||||
}
|
||||
target.addEventListener(eventName, onSuccess, { once: true })
|
||||
target.addEventListener('error', onError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function captureFrame(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D
|
||||
): Promise<string> {
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (width <= 0 || height <= 0) return ''
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
context.drawImage(video, 0, 0, width, height)
|
||||
return canvas.toDataURL('image/jpeg', 0.7)
|
||||
}
|
||||
|
||||
async function sampleFilmstripFrames(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
duration: number,
|
||||
sampleCount: number
|
||||
): Promise<string[]> {
|
||||
const thumbnails: string[] = []
|
||||
const lastIndex = Math.max(sampleCount - 1, 1)
|
||||
|
||||
for (let index = 0; index < sampleCount; index++) {
|
||||
const time = sampleCount <= 1 ? 0 : (duration * index) / lastIndex
|
||||
video.currentTime = Math.min(time, Math.max(duration - 0.001, 0))
|
||||
await waitForEvent(video, 'seeked')
|
||||
const thumbnail = await captureFrame(video, canvas, context)
|
||||
if (thumbnail) thumbnails.push(thumbnail)
|
||||
}
|
||||
|
||||
return thumbnails
|
||||
}
|
||||
|
||||
export function useVideoFilmstrip(
|
||||
videoUrl: Ref<string | undefined>,
|
||||
options: UseVideoFilmstripOptions = {}
|
||||
) {
|
||||
const sampleCount = options.sampleCount ?? FILMSTRIP_SAMPLE_COUNT
|
||||
|
||||
const thumbnails = ref<string[]>([])
|
||||
const duration = ref(0)
|
||||
const totalFrames = ref(0)
|
||||
const width = ref(0)
|
||||
const height = ref(0)
|
||||
const fps = ref(options.fps ?? DEFAULT_VIDEO_FPS)
|
||||
const fileSize = ref<number | undefined>()
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let activeLoadId = 0
|
||||
|
||||
function isLoadStale(loadId: number, url: string) {
|
||||
return loadId !== activeLoadId || videoUrl.value !== url
|
||||
}
|
||||
|
||||
async function loadVideo(url: string) {
|
||||
const loadId = ++activeLoadId
|
||||
loading.value = true
|
||||
error.value = null
|
||||
thumbnails.value = []
|
||||
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.crossOrigin = 'anonymous'
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
loading.value = false
|
||||
error.value = 'Canvas is unavailable'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
video.src = url
|
||||
await waitForEvent(video, 'loadedmetadata')
|
||||
|
||||
if (isLoadStale(loadId, url)) return
|
||||
|
||||
const videoDuration = Number.isFinite(video.duration) ? video.duration : 0
|
||||
duration.value = videoDuration
|
||||
width.value = video.videoWidth
|
||||
height.value = video.videoHeight
|
||||
|
||||
const detectedFileSize = await fetchHttpResourceByteSize(url)
|
||||
|
||||
if (isLoadStale(loadId, url)) return
|
||||
|
||||
const detectedFrameRate = await probeVideoFrameRate(
|
||||
url,
|
||||
videoDuration,
|
||||
detectedFileSize
|
||||
)
|
||||
|
||||
if (isLoadStale(loadId, url)) return
|
||||
|
||||
fps.value = detectedFrameRate ?? options.fps ?? DEFAULT_VIDEO_FPS
|
||||
fileSize.value = detectedFileSize
|
||||
totalFrames.value = Math.max(Math.round(videoDuration * fps.value), 1)
|
||||
|
||||
const sampledThumbnails = await sampleFilmstripFrames(
|
||||
video,
|
||||
canvas,
|
||||
context,
|
||||
videoDuration,
|
||||
sampleCount
|
||||
)
|
||||
|
||||
if (isLoadStale(loadId, url)) return
|
||||
|
||||
thumbnails.value = sampledThumbnails
|
||||
} catch (loadError) {
|
||||
if (isLoadStale(loadId, url)) return
|
||||
error.value =
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load video'
|
||||
duration.value = 0
|
||||
totalFrames.value = 0
|
||||
width.value = 0
|
||||
height.value = 0
|
||||
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
|
||||
fileSize.value = undefined
|
||||
thumbnails.value = []
|
||||
} finally {
|
||||
if (loadId === activeLoadId) {
|
||||
loading.value = false
|
||||
}
|
||||
video.removeAttribute('src')
|
||||
video.load()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
videoUrl,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
activeLoadId++
|
||||
loading.value = false
|
||||
error.value = null
|
||||
thumbnails.value = []
|
||||
duration.value = 0
|
||||
totalFrames.value = 0
|
||||
width.value = 0
|
||||
height.value = 0
|
||||
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
|
||||
fileSize.value = undefined
|
||||
return
|
||||
}
|
||||
void loadVideo(url)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
activeLoadId++
|
||||
})
|
||||
|
||||
return {
|
||||
thumbnails,
|
||||
duration,
|
||||
totalFrames,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
fileSize,
|
||||
loading,
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -815,8 +815,10 @@ export class GroupNodeConfig {
|
||||
* `configure`. The load-time migration unpacks each instance via
|
||||
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
|
||||
* result as a subgraph.
|
||||
*
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
*/
|
||||
class GroupNodeHandler {
|
||||
export class GroupNodeHandler {
|
||||
node: LGraphNode
|
||||
groupData: GroupNodeConfig
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import './imageCompare'
|
||||
import './imageCrop'
|
||||
// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB)
|
||||
// The lazy loader triggers loading when a 3D node is used
|
||||
import './loadVideoTrim'
|
||||
import './load3dLazy'
|
||||
import './maskeditor'
|
||||
if (!isCloud) {
|
||||
|
||||
@@ -343,7 +343,7 @@ useExtensionService().registerExtension({
|
||||
name: inputName,
|
||||
component: Load3D,
|
||||
inputSpec: { ...inputSpecLoad3D, name: inputName },
|
||||
options: {}
|
||||
options: { hideInPanel: true }
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
@@ -566,7 +566,7 @@ useExtensionService().registerExtension({
|
||||
name: inputSpecPreview3D.name,
|
||||
component: Load3D,
|
||||
inputSpec: inputSpecPreview3D,
|
||||
options: {}
|
||||
options: { hideInPanel: true }
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
|
||||
@@ -45,7 +45,7 @@ useExtensionService().registerExtension({
|
||||
name: 'viewport_state',
|
||||
component: Load3DAdvanced,
|
||||
inputSpec: inputSpecLoad3DAdvanced,
|
||||
options: {}
|
||||
options: { hideInPanel: true }
|
||||
})
|
||||
|
||||
widget.type = 'load3DAdvanced'
|
||||
|
||||
17
src/extensions/core/loadVideoTrim.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
import { useVideoTrimWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useVideoTrimWidget'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.LoadVideoTrimPrototype',
|
||||
|
||||
nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'LoadVideo') return
|
||||
|
||||
node.hideOutputImages = true
|
||||
node.setSize([Math.max(node.size[0], 350), node.size[1]])
|
||||
|
||||
useVideoTrimWidget(node)
|
||||
}
|
||||
})
|
||||
@@ -107,7 +107,7 @@ useExtensionService().registerExtension({
|
||||
name: inputSpec.name,
|
||||
component: Load3D,
|
||||
inputSpec,
|
||||
options: {}
|
||||
options: { hideInPanel: true }
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
|
||||
@@ -43,6 +43,14 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
socketless?: boolean
|
||||
/** If `true`, the widget will not be rendered by the Vue renderer. */
|
||||
canvasOnly?: boolean
|
||||
/**
|
||||
* If `true`, the widget still renders on the node but is omitted from the
|
||||
* right side panel. Unlike {@link IWidgetOptions.canvasOnly}, the node body
|
||||
* keeps rendering it via the Vue renderer. Used for widgets that hold
|
||||
* non-syncable state (e.g. a Three.js viewport) where a second instance in
|
||||
* the panel would diverge from the one on the node.
|
||||
*/
|
||||
hideInPanel?: boolean
|
||||
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||
nodeType?: string
|
||||
|
||||
@@ -138,6 +146,7 @@ export type IWidget =
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
| IVideoTrimWidget
|
||||
| IBoundingBoxesWidget
|
||||
| IColorsWidget
|
||||
|
||||
@@ -361,6 +370,12 @@ export interface RangeValue {
|
||||
midpoint?: number
|
||||
}
|
||||
|
||||
export interface VideoTrimValue {
|
||||
trimEnabled: boolean
|
||||
startFrame: number
|
||||
endFrame: number
|
||||
}
|
||||
|
||||
export interface IWidgetRangeOptions extends IWidgetOptions {
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradient_stops?: ColorStop[]
|
||||
@@ -379,6 +394,14 @@ export interface IRangeWidget extends IBaseWidget<
|
||||
value: RangeValue
|
||||
}
|
||||
|
||||
export interface IVideoTrimWidget extends IBaseWidget<
|
||||
VideoTrimValue,
|
||||
'videotrim'
|
||||
> {
|
||||
type: 'videotrim'
|
||||
value: VideoTrimValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
16
src/lib/litegraph/src/widgets/VideoTrimWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IVideoTrimWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class VideoTrimWidget
|
||||
extends BaseWidget<IVideoTrimWidget>
|
||||
implements IVideoTrimWidget
|
||||
{
|
||||
override type = 'videotrim' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Video Trim')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { BoundingBoxesWidget } from './BoundingBoxesWidget'
|
||||
import { ColorsWidget } from './ColorsWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { VideoTrimWidget } from './VideoTrimWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -64,6 +65,7 @@ export type WidgetTypeMap = {
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
videotrim: VideoTrimWidget
|
||||
boundingboxes: BoundingBoxesWidget
|
||||
colors: ColorsWidget
|
||||
[key: string]: BaseWidget
|
||||
@@ -148,6 +150,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
case 'videotrim':
|
||||
return toClass(VideoTrimWidget, narrowedWidget, node)
|
||||
case 'boundingboxes':
|
||||
return toClass(BoundingBoxesWidget, narrowedWidget, node)
|
||||
case 'colors':
|
||||
|
||||
@@ -3017,6 +3017,7 @@
|
||||
"placeholderImage": "Select image...",
|
||||
"placeholderAudio": "Select audio...",
|
||||
"placeholderVideo": "Select video...",
|
||||
"browseAssetLibrary": "Browse asset library",
|
||||
"placeholderMesh": "Select mesh...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media...",
|
||||
@@ -3605,6 +3606,7 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"publishButton": "Publish to ComfyHub",
|
||||
"updateButton": "Update workflow",
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
@@ -3619,6 +3621,8 @@
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.",
|
||||
"renameFailedTitle": "Rename failed",
|
||||
"renameFailedDescription": "Your workflow was published successfully, but renaming the local file failed. Rename it again to match.",
|
||||
"publishSuccessTitle": "Published successfully",
|
||||
"publishSuccessDescription": "Your workflow is now live on ComfyHub."
|
||||
},
|
||||
@@ -3627,9 +3631,12 @@
|
||||
"profileCreationNav": "Profile creation",
|
||||
"introTitle": "Publish to the ComfyHub",
|
||||
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
|
||||
"updateIntroTitle": "Update your ComfyHub workflow",
|
||||
"updateIntroDescription": "Push your latest changes to ComfyHub. Your share link and stats stay the same.",
|
||||
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
|
||||
"createProfileButton": "Create my profile",
|
||||
"startPublishingButton": "Start publishing",
|
||||
"startUpdatingButton": "Update workflow",
|
||||
"modalTitle": "Create your profile on ComfyHub",
|
||||
"createProfileTitle": "Create your Comfy Hub profile",
|
||||
"uploadCover": "+ Upload a cover",
|
||||
@@ -4471,6 +4478,32 @@
|
||||
"continueLocally": "Continue Locally",
|
||||
"exploreCloud": "Try Cloud for Free"
|
||||
},
|
||||
"loadVideoTrim": {
|
||||
"trimVideo": "Trim Video",
|
||||
"startFrame": "Start Frame",
|
||||
"endFrame": "End Frame",
|
||||
"setStartFrame": "Set start frame",
|
||||
"setEndFrame": "Set end frame",
|
||||
"duration": "Duration",
|
||||
"frames": "Number of Frames",
|
||||
"fileSize": "File Size",
|
||||
"resolution": "{width} × {height}",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"dragAndDropVideos": "Drag and drop videos here to upload",
|
||||
"uploadFromDevice": "Upload from device",
|
||||
"uploading": "Uploading…",
|
||||
"loadingVideo": "Loading video preview",
|
||||
"loadingFilmstrip": "Loading filmstrip…",
|
||||
"adjustStartFrame": "Adjust start frame",
|
||||
"adjustEndFrame": "Adjust end frame",
|
||||
"durationZero": "0s",
|
||||
"durationSeconds": "{count}s",
|
||||
"fileSizeUnknown": "—",
|
||||
"fileSizeBytes": "{count} B",
|
||||
"fileSizeKilobytes": "{count} KB",
|
||||
"fileSizeMegabytes": "{count} MB"
|
||||
},
|
||||
"execution": {
|
||||
"generating": "Generating…",
|
||||
"saving": "Saving…",
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ResolveModelNodeError {
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResolvedModelNode {
|
||||
interface ResolvedModelNode {
|
||||
provider: ModelNodeProvider
|
||||
filename: string
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
export interface AccountPreconditionContext {
|
||||
interface AccountPreconditionContext {
|
||||
/** Node type that triggered the precondition, used as modal context. */
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export type SubscriptionDialogReason =
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
/**
|
||||
* Forces the unified pricing dialog to open on a specific plan tab,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface MonthlyCreditsUsage {
|
||||
interface MonthlyCreditsUsage {
|
||||
/** Credits consumed from the monthly allowance (never negative). */
|
||||
used: number
|
||||
/** Fraction (0–1) of the monthly allowance consumed — drives the bar fill. */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sumBy } from 'es-toolkit'
|
||||
|
||||
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
|
||||
|
||||
export interface MissingMediaReference {
|
||||
interface MissingMediaReference {
|
||||
mediaItem: MissingMediaViewModel
|
||||
nodeRef: MissingMediaViewModel['referencingNodes'][number]
|
||||
}
|
||||
|
||||
@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const trackSettingChanged = vi.fn()
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackSettingChanged
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockSet = vi.fn()
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
|
||||
template: '<div data-testid="form-item-stub" />'
|
||||
})
|
||||
|
||||
describe('SettingItem (telemetry UI tracking)', () => {
|
||||
describe('SettingItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
emitFormValue = null
|
||||
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
|
||||
it('persists setting updates through the setting store', async () => {
|
||||
const settingParams: SettingParams = {
|
||||
id: 'main.sub.setting.name',
|
||||
name: 'Telemetry Visible',
|
||||
name: 'Visible Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
}
|
||||
|
||||
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
|
||||
mockGet.mockReturnValue('default')
|
||||
mockSet.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent(settingParams)
|
||||
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setting_id: 'main.sub.setting.name',
|
||||
previous_value: 'default',
|
||||
new_value: 'normalized'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track telemetry when normalized value does not change', async () => {
|
||||
const settingParams: SettingParams = {
|
||||
id: 'main.sub.setting.name',
|
||||
name: 'Telemetry Visible',
|
||||
type: 'text',
|
||||
defaultValue: 'same'
|
||||
}
|
||||
|
||||
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
|
||||
mockSet.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent(settingParams)
|
||||
|
||||
emitFormValue!('same')
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
|
||||
const updateSettingValue = async <K extends keyof Settings>(
|
||||
newValue: Settings[K]
|
||||
) => {
|
||||
const telemetry = useTelemetry()
|
||||
const settingId = props.setting.id
|
||||
const previousValue = settingValue.value
|
||||
|
||||
await settingStore.set(settingId, newValue)
|
||||
|
||||
const normalizedValue = settingStore.get(settingId)
|
||||
if (previousValue !== normalizedValue) {
|
||||
telemetry?.trackSettingChanged({
|
||||
setting_id: settingId,
|
||||
previous_value: previousValue,
|
||||
new_value: normalizedValue
|
||||
})
|
||||
}
|
||||
await settingStore.set(props.setting.id, newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
versionModified: '1.6.7',
|
||||
telemetry: { trackChanges: true, includeValues: true },
|
||||
migrateDeprecatedValue(val: unknown) {
|
||||
const value = val as string
|
||||
// Legacy custom palettes were prefixed with 'custom_'
|
||||
|
||||
@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { trackSettingChanged } = vi.hoisted(() => ({
|
||||
trackSettingChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackSettingChanged
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
|
||||
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
|
||||
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
|
||||
|
||||
// Set the same value again, it should not trigger onChange
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
||||
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Set a different value, it should trigger onChange
|
||||
await store.set('test.setting', 'differentvalue')
|
||||
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
|
||||
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks visible settings with values by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting',
|
||||
previous_value: 'default',
|
||||
new_value: 'newvalue'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track hidden settings by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'hidden',
|
||||
defaultValue: 'default'
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track visible settings that opt out', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: false }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks visible settings without values when values opt out', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { includeValues: false }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks hidden settings that opt in, without shipping values by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'hidden',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: true }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting'
|
||||
})
|
||||
|
||||
// Setting the same value again is a no-op and should not re-emit
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ships previous/new values when the setting opts into includeValues', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.ColorPalette',
|
||||
name: 'The active color palette id',
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
telemetry: { trackChanges: true, includeValues: true }
|
||||
})
|
||||
|
||||
await store.set('Comfy.ColorPalette', 'light')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'Comfy.ColorPalette',
|
||||
previous_value: 'dark',
|
||||
new_value: 'light'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track telemetry when persistence fails', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: true }
|
||||
})
|
||||
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
|
||||
|
||||
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
|
||||
'failed'
|
||||
)
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('object mutation prevention', () => {
|
||||
beforeEach(() => {
|
||||
const setting: SettingParams = {
|
||||
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
|
||||
expect(api.storeSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks only the settings in a batch that opt in', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.ColorPalette',
|
||||
name: 'The active color palette id',
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
telemetry: { trackChanges: true, includeValues: true }
|
||||
})
|
||||
store.addSetting({
|
||||
id: 'Comfy.Release.Version',
|
||||
name: 'Release Version',
|
||||
type: 'hidden',
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
await store.setMany({
|
||||
'Comfy.ColorPalette': 'light',
|
||||
'Comfy.Release.Version': '1.0.0'
|
||||
})
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'Comfy.ColorPalette',
|
||||
previous_value: 'dark',
|
||||
new_value: 'light'
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip unchanged values', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.Release.Version',
|
||||
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
|
||||
await store.setMany({ 'Comfy.Release.Version': 'existing' })
|
||||
|
||||
expect(api.storeSettings).not.toHaveBeenCalled()
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
interface AppliedSetting<TValue> {
|
||||
previousValue: TValue
|
||||
newValue: TValue
|
||||
}
|
||||
|
||||
function tryMigrateDeprecatedValue(
|
||||
setting: SettingParams | undefined,
|
||||
value: unknown
|
||||
@@ -45,6 +52,28 @@ function onChange(
|
||||
}
|
||||
}
|
||||
|
||||
function settingChangedEvent<K extends keyof Settings>(
|
||||
setting: SettingParams | undefined,
|
||||
key: K,
|
||||
applied: AppliedSetting<Settings[K]>
|
||||
): SettingChangedMetadata | undefined {
|
||||
if (!setting) return undefined
|
||||
|
||||
const telemetry = setting.telemetry
|
||||
const isVisible = setting.type !== 'hidden'
|
||||
const trackChanges = telemetry?.trackChanges ?? isVisible
|
||||
if (!trackChanges) return undefined
|
||||
|
||||
const includeValues = telemetry?.includeValues ?? isVisible
|
||||
return includeValues
|
||||
? {
|
||||
setting_id: key,
|
||||
previous_value: applied.previousValue,
|
||||
new_value: applied.newValue
|
||||
}
|
||||
: { setting_id: key }
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore('setting', () => {
|
||||
const settingValues = ref<Partial<Settings>>({})
|
||||
const settingsById = ref<Record<string, SettingParams>>({})
|
||||
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
function applySettingLocally<K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K]
|
||||
): Settings[K] | undefined {
|
||||
): AppliedSetting<Settings[K]> | undefined {
|
||||
const clonedValue = _.cloneDeep(value)
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
if (newValue === oldValue) return undefined
|
||||
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
return newValue as Settings[K]
|
||||
const typedNewValue = newValue as Settings[K]
|
||||
settingValues.value[key] = typedNewValue
|
||||
return {
|
||||
previousValue: oldValue,
|
||||
newValue: typedNewValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
|
||||
const applied = applySettingLocally(key, value)
|
||||
if (applied === undefined) return
|
||||
await api.storeSetting(key, applied)
|
||||
await api.storeSetting(key, applied.newValue)
|
||||
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) useTelemetry()?.trackSettingChanged(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
*/
|
||||
async function setMany(settings: Partial<Settings>) {
|
||||
const updatedSettings: Partial<Settings> = {}
|
||||
const telemetryEvents: SettingChangedMetadata[] = []
|
||||
|
||||
for (const key of Object.keys(settings) as (keyof Settings)[]) {
|
||||
const applied = applySettingLocally(
|
||||
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
settings[key] as Settings[typeof key]
|
||||
)
|
||||
if (applied !== undefined) {
|
||||
updatedSettings[key] = applied
|
||||
updatedSettings[key] = applied.newValue
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) telemetryEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatedSettings).length > 0) {
|
||||
await api.storeSettings(updatedSettings)
|
||||
const telemetry = useTelemetry()
|
||||
for (const event of telemetryEvents) {
|
||||
telemetry?.trackSettingChanged(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,22 @@ export interface SettingOption {
|
||||
value?: string | number
|
||||
}
|
||||
|
||||
type SettingTelemetryOptions =
|
||||
| {
|
||||
trackChanges: false
|
||||
includeValues?: never
|
||||
}
|
||||
| {
|
||||
trackChanges?: true
|
||||
includeValues?: boolean
|
||||
}
|
||||
|
||||
export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
id: keyof Settings
|
||||
defaultValue: TValue | (() => TValue)
|
||||
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
|
||||
onChange?(newValue: TValue, oldValue?: TValue): void
|
||||
telemetry?: SettingTelemetryOptions
|
||||
// By default category is id.split('.'). However, changing id to assign
|
||||
// new category has poor backward compatibility. Use this field to overwrite
|
||||
// default category from id.
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
@@ -635,7 +635,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
it('captures legacy page view event with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -645,9 +645,13 @@ describe('PostHogTelemetryProvider', () => {
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards additional metadata', async () => {
|
||||
it('forwards additional metadata to legacy page view event', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -660,6 +664,20 @@ describe('PostHogTelemetryProvider', () => {
|
||||
{ page_name: 'workflow_editor', path: '/workflows/123' }
|
||||
)
|
||||
})
|
||||
|
||||
it('queues legacy page view event before initialization', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackPageView('workflow_editor')
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('before_send', () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
<ComfyHubPublishIntroPanel
|
||||
v-else
|
||||
data-testid="publish-intro"
|
||||
:is-update="!!publishResult"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
:show-close-button="false"
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
<!-- Content -->
|
||||
<section class="flex flex-col items-center gap-4 px-4 pt-4 pb-6">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('comfyHubProfile.introTitle') }}
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.updateIntroTitle')
|
||||
: $t('comfyHubProfile.introTitle')
|
||||
}}
|
||||
</h2>
|
||||
<p class="m-0 text-center text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubProfile.introDescription') }}
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.updateIntroDescription')
|
||||
: $t('comfyHubProfile.introDescription')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -26,7 +34,11 @@
|
||||
class="mt-2 w-full"
|
||||
@click="onCreateProfile"
|
||||
>
|
||||
{{ $t('comfyHubProfile.startPublishingButton') }}
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubProfile.startUpdatingButton')
|
||||
: $t('comfyHubProfile.startPublishingButton')
|
||||
}}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -38,10 +50,12 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
const {
|
||||
onCreateProfile,
|
||||
onClose,
|
||||
showCloseButton = true
|
||||
showCloseButton = true,
|
||||
isUpdate = false
|
||||
} = defineProps<{
|
||||
onCreateProfile: () => void
|
||||
onClose: () => void
|
||||
showCloseButton?: boolean
|
||||
isUpdate?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
@@ -30,6 +30,10 @@ const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
const mockRenameWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockFormDataHolder = vi.hoisted(
|
||||
() => ({ value: null }) as { value: Record<string, unknown> | null }
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
@@ -42,35 +46,41 @@ vi.mock(
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
|
||||
() => ({
|
||||
useComfyHubPublishWizard: () => ({
|
||||
currentStep: ref('finish'),
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
() => {
|
||||
mockFormDataHolder.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
existingThumbnailType: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
return {
|
||||
useComfyHubPublishWizard: () => ({
|
||||
currentStep: ref('finish'),
|
||||
formData: ref(mockFormDataHolder.value),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
goToStep: mockGoToStep,
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
goToStep: mockGoToStep,
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
})
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
@@ -90,23 +100,44 @@ vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
renameWorkflow: vi.fn(),
|
||||
renameWorkflow: mockRenameWorkflow,
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
const mockWorkflowStore = vi.hoisted(() => {
|
||||
return {
|
||||
instance: null as { activeWorkflow: Record<string, unknown> | null } | null
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
mockWorkflowStore.instance = reactive({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
} as Record<string, unknown> | null
|
||||
})
|
||||
}))
|
||||
return {
|
||||
useWorkflowStore: () => ({
|
||||
...mockWorkflowStore.instance,
|
||||
get activeWorkflow() {
|
||||
return mockWorkflowStore.instance?.activeWorkflow ?? null
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function setActiveWorkflow(workflow: Record<string, unknown> | null) {
|
||||
if (mockWorkflowStore.instance) {
|
||||
mockWorkflowStore.instance.activeWorkflow = workflow
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
@@ -117,8 +148,17 @@ describe('ComfyHubPublishDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
mockSubmitToComfyHub.mockResolvedValue(undefined)
|
||||
mockRenameWorkflow.mockResolvedValue(undefined)
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = ''
|
||||
mockGetCachedPrefill.mockReturnValue(null)
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
@@ -226,6 +266,119 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renames the local workflow when the published name differs', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRenameWorkflow).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'workflows/renamed.json'
|
||||
)
|
||||
expect(mockSubmitToComfyHub.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRenameWorkflow.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rename when the published name matches the file name', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'test'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRenameWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports success but warns when the post-publish rename fails', async () => {
|
||||
mockRenameWorkflow.mockRejectedValueOnce(new Error('rename failed'))
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not rename or close when publish submission fails', async () => {
|
||||
mockSubmitToComfyHub.mockRejectedValueOnce(new Error('submit failed'))
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(mockRenameWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not refetch publish status when the rename changes the path mid-publish', async () => {
|
||||
mockRenameWorkflow.mockImplementationOnce(async () => {
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
})
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
mockGetPublishStatus.mockClear()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).not.toHaveBeenCalledWith(
|
||||
'workflows/renamed.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('caches the prefill under the renamed path after publish', async () => {
|
||||
mockRenameWorkflow.mockImplementationOnce(async () => {
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
})
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
|
||||
|
||||
await userEvent.click(screen.getByTestId('publish'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCachePublishPrefill).toHaveBeenCalledWith(
|
||||
'workflows/renamed.json',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('applies prefill when workflow is already published with metadata', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
@@ -282,4 +435,95 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to cached prefill when the status fetch fails', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
|
||||
const cached = { description: 'cached' }
|
||||
mockGetCachedPrefill.mockReturnValue(cached)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).toHaveBeenCalledWith(cached)
|
||||
})
|
||||
|
||||
it('refetches prefill when the active workflow path changes (e.g. rename)', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
expect(mockGetPublishStatus).toHaveBeenLastCalledWith('workflows/test.json')
|
||||
|
||||
mockGetPublishStatus.mockClear()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).toHaveBeenCalledWith('workflows/renamed.json')
|
||||
})
|
||||
|
||||
it('does not refetch prefill when the active workflow path is unchanged', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
mockGetPublishStatus.mockClear()
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: true
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetPublishStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a stale prefill response after the workflow path changes', async () => {
|
||||
const stalePrefill = { description: 'stale' }
|
||||
let resolveStale: (value: unknown) => void = () => {}
|
||||
mockGetPublishStatus.mockImplementation((path: string) => {
|
||||
if (path === 'workflows/test.json') {
|
||||
return new Promise((resolve) => {
|
||||
resolveStale = resolve
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
isPublished: true,
|
||||
shareId: 'fresh',
|
||||
shareUrl: null,
|
||||
publishedAt: new Date(),
|
||||
prefill: { description: 'fresh' }
|
||||
})
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
setActiveWorkflow({
|
||||
path: 'workflows/renamed.json',
|
||||
filename: 'renamed.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
resolveStale({
|
||||
isPublished: true,
|
||||
shareId: 'stale',
|
||||
shareUrl: null,
|
||||
publishedAt: new Date(),
|
||||
prefill: stalePrefill
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalledWith(stalePrefill)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publishing
|
||||
:is-update="isAlreadyPublished"
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
@@ -129,6 +130,7 @@ const {
|
||||
applyPrefill
|
||||
} = useComfyHubPublishWizard()
|
||||
const isPublishing = ref(false)
|
||||
const isAlreadyPublished = ref(false)
|
||||
const needsSave = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
@@ -205,6 +207,27 @@ function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
async function syncWorkflowName(): Promise<void> {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow || workflow.isTemporary) return
|
||||
|
||||
const desiredName = formData.value.name.trim().replace(/\.json$/i, '')
|
||||
const currentName = workflow.filename.replace(/\.json$/i, '')
|
||||
if (!desiredName || desiredName === currentName) return
|
||||
|
||||
const newPath = buildWorkflowPath(workflow.directory, desiredName)
|
||||
try {
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to rename workflow after publish:', error)
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('comfyHubPublish.renameFailedTitle'),
|
||||
detail: t('comfyHubPublish.renameFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(): Promise<void> {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
@@ -213,6 +236,7 @@ async function handlePublish(): Promise<void> {
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await submitToComfyHub(formData.value)
|
||||
await syncWorkflowName()
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
@@ -242,10 +266,15 @@ function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
|
||||
async function fetchPublishPrefill() {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return
|
||||
if (!path) {
|
||||
isAlreadyPublished.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(path)
|
||||
if (workflowStore.activeWorkflow?.path !== path) return
|
||||
isAlreadyPublished.value = status.isPublished
|
||||
const prefill = status.isPublished
|
||||
? (status.prefill ?? getCachedPrefill(path))
|
||||
: getCachedPrefill(path)
|
||||
@@ -253,6 +282,8 @@ async function fetchPublishPrefill() {
|
||||
applyPrefill(prefill)
|
||||
}
|
||||
} catch (error) {
|
||||
if (workflowStore.activeWorkflow?.path !== path) return
|
||||
isAlreadyPublished.value = false
|
||||
console.warn('Failed to fetch publish prefill:', error)
|
||||
const cached = getCachedPrefill(path)
|
||||
if (cached) {
|
||||
@@ -267,6 +298,15 @@ onMounted(() => {
|
||||
void fetchPublishPrefill()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
(newPath, oldPath) => {
|
||||
if (isPublishing.value) return
|
||||
if (!newPath || newPath === oldPath) return
|
||||
void fetchPublishPrefill()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
if (image.file) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
|
||||
function renderFooter(props: Record<string, unknown> = {}) {
|
||||
return render(ComfyHubPublishFooter, {
|
||||
props: { isFirstStep: false, isLastStep: true, ...props },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubPublishFooter', () => {
|
||||
it('shows the publish label for a new workflow', () => {
|
||||
renderFooter({ isUpdate: false })
|
||||
expect(screen.getByText('comfyHubPublish.publishButton')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the update label when the workflow is already published', () => {
|
||||
renderFooter({ isUpdate: true })
|
||||
expect(screen.getByText('comfyHubPublish.updateButton')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -23,13 +23,26 @@
|
||||
:loading="isPublishing"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isUpdate ? 'icon-[lucide--refresh-cw]' : 'icon-[lucide--upload]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{
|
||||
isUpdate
|
||||
? $t('comfyHubPublish.updateButton')
|
||||
: $t('comfyHubPublish.publishButton')
|
||||
}}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -37,6 +50,7 @@ defineProps<{
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
isPublishing?: boolean
|
||||
isUpdate?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||