mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
15 Commits
load-video
...
pysssss/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04624eb8de | ||
|
|
cb3765ba8f | ||
|
|
7546a6e960 | ||
|
|
b40974868a | ||
|
|
7b638a995a | ||
|
|
6dc7e6f4a2 | ||
|
|
0d16721eb9 | ||
|
|
8957d61a32 | ||
|
|
2f652aab92 | ||
|
|
10b89ee889 | ||
|
|
4f33013411 | ||
|
|
78421d9990 | ||
|
|
b02d2fea85 | ||
|
|
8fb5f49505 | ||
|
|
218b3cb260 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -96,7 +96,4 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.vercel
|
||||
.env*
|
||||
!.env_example
|
||||
.amp
|
||||
@@ -15,7 +15,6 @@ type CardAction =
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
@@ -27,27 +26,25 @@ export interface FeatureCard {
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
subtitle,
|
||||
columns = 3,
|
||||
cards,
|
||||
copyLabel,
|
||||
copiedLabel
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
columns?: 2 | 3 | 4
|
||||
cards: readonly FeatureCard[]
|
||||
copyLabel?: string
|
||||
copiedLabel?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
const columnClass: Record<2 | 3 | 4, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
@@ -102,7 +99,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
variant="outline"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
|
||||
@@ -67,7 +67,6 @@ export const externalLinks = {
|
||||
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: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1895,31 +1895,31 @@ const translations = {
|
||||
'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.',
|
||||
en: 'Add Comfy Cloud as a built-in connector in Claude, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'将 Comfy Cloud 添加为 Claude 的内置连接器,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
en: 'Open Claude settings',
|
||||
'zh-CN': '打开 Claude 设置'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
en: 'Launch the app or open claude.ai and go to Settings > Connections',
|
||||
'zh-CN': '启动应用或打开 claude.ai,前往"设置 > 连接"'
|
||||
},
|
||||
'mcp.setup.step1.cta': {
|
||||
en: 'SETTINGS → CONNECTIONS',
|
||||
'zh-CN': '设置 > 连接'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
en: 'Add the Comfy Cloud custom connector',
|
||||
'zh-CN': '添加 Comfy Cloud 自定义连接器'
|
||||
},
|
||||
'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 文档'
|
||||
en: 'Name it Comfy Cloud and paste the URL',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
@@ -1927,12 +1927,9 @@ const translations = {
|
||||
'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 技能'
|
||||
en: "Click Add > Connect, sign in with your Comfy account. You're all set. Now just ask Claude to generate an image.",
|
||||
'zh-CN':
|
||||
'点击"添加 > 连接",使用 Comfy 账户登录。配置完成。现在直接让 Claude 生成图像即可。'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
|
||||
@@ -16,8 +16,11 @@ const cards: FeatureCard[] = [
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step1.cta', locale),
|
||||
href: `${externalLinks.cloud}/settings/connections`,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -26,27 +29,15 @@ const cards: FeatureCard[] = [
|
||||
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'
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
description: t('mcp.setup.step3.description', locale)
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -21,12 +21,18 @@ export class CancelSubscriptionDialog extends BaseDialog {
|
||||
})
|
||||
}
|
||||
|
||||
async open(cancelAt?: string) {
|
||||
/** Launches the cancellation flow without waiting for the legacy dialog
|
||||
* (e.g. when the Churnkey embed is expected to handle it instead). */
|
||||
async launch(cancelAt?: string) {
|
||||
await this.page.evaluate((date) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showCancelSubscriptionDialog(date)
|
||||
).dialog.launchCancellationFlow(date)
|
||||
}, cancelAt)
|
||||
}
|
||||
|
||||
async open(cancelAt?: string) {
|
||||
await this.launch(cancelAt)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
export class FeatureFlagHelper {
|
||||
private featuresRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
@@ -51,6 +53,68 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server feature flags at runtime by mutating the reactive
|
||||
* `api.serverFeatureFlags` ref. Use this when `setFlags()` (localStorage)
|
||||
* won't work — namely in production builds, where the dev-override
|
||||
* reader is gated on `import.meta.env.DEV` and dead-code-eliminated.
|
||||
*
|
||||
* Note: server features are the LOWEST-priority flag source. If the
|
||||
* backend's remote config (`/api/features`) defines the same key, the
|
||||
* remote-config value wins — use `overrideFlags()` to control flags
|
||||
* deterministically regardless of what the backend serves.
|
||||
*/
|
||||
async setServerFeatures(features: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, features)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically override flags resolved via `useFeatureFlags()` in
|
||||
* production cloud builds, where dev overrides (the highest-priority
|
||||
* source) are compiled out. Covers both remaining sources:
|
||||
*
|
||||
* 1. Remote config — mutates the live config object in place
|
||||
* (`window.__CONFIG__` is the same object held by the `remoteConfig`
|
||||
* ref, whose consumers read keys lazily on access) and intercepts
|
||||
* `/api/features` so any later refresh (auth change, 10-minute poll)
|
||||
* re-applies the overrides instead of clobbering them.
|
||||
* 2. Server features — mutates `api.serverFeatureFlags` as a fallback
|
||||
* for environments where remote config never loaded.
|
||||
*/
|
||||
async overrideFlags(features: Record<string, unknown>): Promise<void> {
|
||||
await this.page.route('**/api/features', async (route) => {
|
||||
const response = await route.fetch()
|
||||
let config: RemoteConfig = {}
|
||||
try {
|
||||
config = (await response.json()) as RemoteConfig
|
||||
} catch {
|
||||
// Non-JSON response (e.g. backend without the endpoint); serve
|
||||
// just the overrides.
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...config, ...features })
|
||||
})
|
||||
})
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const config = (window as { __CONFIG__?: Record<string, unknown> })
|
||||
.__CONFIG__
|
||||
if (config) Object.assign(config, flagMap)
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, features)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
144
browser_tests/tests/cloud/cancellationFlow.spec.ts
Normal file
144
browser_tests/tests/cloud/cancellationFlow.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
|
||||
|
||||
import type { ChurnkeyInitConfig } from '@/platform/cloud/churnkey/types'
|
||||
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const CANCEL_AT = '2026-12-31T12:00:00Z'
|
||||
const STUB_APP_ID = 'e2e-stub'
|
||||
|
||||
const VALID_AUTH_RESPONSE = {
|
||||
customer_id: 'cus_e2e_test',
|
||||
auth_hash: 'fake-hmac',
|
||||
mode: 'test'
|
||||
} satisfies ChurnkeyAuthResponse
|
||||
|
||||
// The production router's catch-all body for undeployed routes (verified
|
||||
// against cloud.comfy.org) — what the frontend sees until the backend
|
||||
// ships the endpoint.
|
||||
const NOT_DEPLOYED_RESPONSE = {
|
||||
error: { message: 'Not Found', type: 'not_found' }
|
||||
}
|
||||
|
||||
interface ChurnkeyInitCall {
|
||||
action: string
|
||||
config: ChurnkeyInitConfig
|
||||
}
|
||||
|
||||
interface ChurnkeyStubWindow extends Window {
|
||||
__churnkeyCalls?: ChurnkeyInitCall[]
|
||||
}
|
||||
|
||||
async function stubChurnkey(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const w = window as ChurnkeyStubWindow
|
||||
w.__churnkeyCalls = []
|
||||
// Defining `init` up front also makes the client skip injecting the
|
||||
// real embed script.
|
||||
w.churnkey = {
|
||||
created: true,
|
||||
init: (action, config) => {
|
||||
w.__churnkeyCalls!.push({ action, config })
|
||||
},
|
||||
clearState: () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const AUTH_ROUTE_GLOB = '**/api/billing/churnkey/auth'
|
||||
|
||||
async function mockAuthEndpoint(
|
||||
page: Page,
|
||||
fulfill:
|
||||
| { status: 200; body: ChurnkeyAuthResponse }
|
||||
| { status: 404; body: typeof NOT_DEPLOYED_RESPONSE }
|
||||
): Promise<void> {
|
||||
await page.route(AUTH_ROUTE_GLOB, (route) =>
|
||||
route.fulfill({
|
||||
status: fulfill.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(fulfill.body)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function getChurnkeyInitCalls(page: Page): Promise<ChurnkeyInitCall[]> {
|
||||
return page.evaluate(
|
||||
() => (window as ChurnkeyStubWindow).__churnkeyCalls ?? []
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Cancellation flow routing', { tag: '@cloud' }, () => {
|
||||
let dialog: CancelSubscriptionDialog
|
||||
|
||||
test.use({ timezoneId: 'UTC' })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new CancelSubscriptionDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test.describe('app id not set', () => {
|
||||
test('routes to the legacy cancel dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.overrideFlags({
|
||||
churnkey_app_id: ''
|
||||
})
|
||||
|
||||
await dialog.open(CANCEL_AT)
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.root).toContainText('December 31, 2026')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('app id set', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.overrideFlags({
|
||||
churnkey_app_id: STUB_APP_ID
|
||||
})
|
||||
await stubChurnkey(comfyPage.page)
|
||||
})
|
||||
|
||||
test('routes to the legacy dialog when auth endpoint 404s', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await mockAuthEndpoint(comfyPage.page, {
|
||||
status: 404,
|
||||
body: NOT_DEPLOYED_RESPONSE
|
||||
})
|
||||
|
||||
await dialog.open(CANCEL_AT)
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
expect(await getChurnkeyInitCalls(comfyPage.page)).toEqual([])
|
||||
})
|
||||
|
||||
test('launches the Churnkey embed when auth returns valid credentials', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await mockAuthEndpoint(comfyPage.page, {
|
||||
status: 200,
|
||||
body: VALID_AUTH_RESPONSE
|
||||
})
|
||||
|
||||
await dialog.launch(CANCEL_AT)
|
||||
|
||||
await expect
|
||||
.poll(() => getChurnkeyInitCalls(comfyPage.page).then((c) => c.length))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const [firstCall] = await getChurnkeyInitCalls(comfyPage.page)
|
||||
expect(firstCall.action).toBe('show')
|
||||
expect(firstCall.config).toMatchObject({
|
||||
authHash: 'fake-hmac',
|
||||
customerId: 'cus_e2e_test',
|
||||
mode: 'test',
|
||||
provider: 'stripe'
|
||||
})
|
||||
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -266,9 +266,6 @@
|
||||
--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);
|
||||
@@ -552,10 +549,6 @@
|
||||
);
|
||||
--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,6 +70,8 @@ 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>
|
||||
<DropdownMenu button-class="icon-[lucide--list-filter]">
|
||||
<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="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
|
||||
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
|
||||
/>
|
||||
<DropdownMenuItemIndicator class="size-4 shrink-0">
|
||||
<i class="icon-[lucide--check]" />
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
|
||||
import {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
@@ -1,62 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,169 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,501 +0,0 @@
|
||||
<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>
|
||||
@@ -1,80 +0,0 @@
|
||||
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"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
<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>
|
||||
@@ -1,367 +0,0 @@
|
||||
/* 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)]])
|
||||
})
|
||||
})
|
||||
@@ -1,365 +0,0 @@
|
||||
<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>
|
||||
@@ -1,5 +0,0 @@
|
||||
export function timelineInsetLeftStyle(normalized: number) {
|
||||
return {
|
||||
left: `calc(${normalized} * (100% - 2rem) + 1rem)`
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,12 @@ export function resolveEssentialTileNodeDef(
|
||||
): ComfyNodeDefImpl | undefined {
|
||||
const name = tile.nodeName
|
||||
if (!name) return undefined
|
||||
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
|
||||
return nodeDefStore.allNodeDefsByName[name]
|
||||
|
||||
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
|
||||
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)
|
||||
}
|
||||
|
||||
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum ServerFeatureFlag {
|
||||
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
CHURNKEY_APP_ID = 'churnkey_app_id',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
@@ -162,6 +163,14 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
},
|
||||
get churnkeyAppId() {
|
||||
if (!isCloud) return ''
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.CHURNKEY_APP_ID,
|
||||
remoteConfig.value.churnkey_app_id,
|
||||
''
|
||||
)
|
||||
},
|
||||
get showSignInButton(): boolean | undefined {
|
||||
return api.getServerFeature<boolean | undefined>(
|
||||
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
|
||||
|
||||
@@ -50,7 +50,6 @@ interface HarnessOptions {
|
||||
valueMax?: number
|
||||
showMidpoint?: boolean
|
||||
track?: HTMLElement | null
|
||||
contentInsetX?: number
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -73,7 +72,6 @@ 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({
|
||||
@@ -83,8 +81,7 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax,
|
||||
showMidpoint,
|
||||
contentInsetX
|
||||
showMidpoint
|
||||
})
|
||||
return () => null
|
||||
}
|
||||
@@ -326,44 +323,4 @@ 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,7 +14,6 @@ interface UseRangeEditorOptions {
|
||||
valueMin: Ref<number>
|
||||
valueMax: Ref<number>
|
||||
showMidpoint: Ref<boolean>
|
||||
contentInsetX?: Ref<number>
|
||||
}
|
||||
|
||||
export function useRangeEditor({
|
||||
@@ -22,8 +21,7 @@ export function useRangeEditor({
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax,
|
||||
showMidpoint,
|
||||
contentInsetX
|
||||
showMidpoint
|
||||
}: UseRangeEditorOptions) {
|
||||
const activeHandle = ref<HandleType | null>(null)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
@@ -32,13 +30,7 @@ export function useRangeEditor({
|
||||
const el = trackRef.value
|
||||
if (!el) return valueMin.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
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
|
||||
)
|
||||
const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||
return denormalize(normalized, valueMin.value, valueMax.value)
|
||||
}
|
||||
|
||||
@@ -116,7 +108,6 @@ export function useRangeEditor({
|
||||
|
||||
return {
|
||||
handleTrackPointerDown,
|
||||
startDrag,
|
||||
activeHandle
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
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,10 +815,8 @@ 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
|
||||
*/
|
||||
export class GroupNodeHandler {
|
||||
class GroupNodeHandler {
|
||||
node: LGraphNode
|
||||
groupData: GroupNodeConfig
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ 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) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -146,7 +146,6 @@ export type IWidget =
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
| IVideoTrimWidget
|
||||
| IBoundingBoxesWidget
|
||||
| IColorsWidget
|
||||
|
||||
@@ -370,12 +369,6 @@ 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[]
|
||||
@@ -394,14 +387,6 @@ 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[]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,7 +25,6 @@ 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'
|
||||
@@ -65,7 +64,6 @@ export type WidgetTypeMap = {
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
videotrim: VideoTrimWidget
|
||||
boundingboxes: BoundingBoxesWidget
|
||||
colors: ColorsWidget
|
||||
[key: string]: BaseWidget
|
||||
@@ -150,8 +148,6 @@ 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':
|
||||
|
||||
@@ -2543,7 +2543,7 @@
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
},
|
||||
"cancelSuccess": "Subscription cancelled successfully",
|
||||
"cancelSuccess": "Subscription canceled successfully",
|
||||
"cancelDialog": {
|
||||
"title": "Cancel subscription",
|
||||
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
|
||||
@@ -2575,6 +2575,7 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsIncluded": "Included",
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
@@ -3017,7 +3018,6 @@
|
||||
"placeholderImage": "Select image...",
|
||||
"placeholderAudio": "Select audio...",
|
||||
"placeholderVideo": "Select video...",
|
||||
"browseAssetLibrary": "Browse asset library",
|
||||
"placeholderMesh": "Select mesh...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media...",
|
||||
@@ -4478,32 +4478,6 @@
|
||||
"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…",
|
||||
|
||||
238
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal file
238
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
|
||||
import type { ChurnkeyWindow } from './types'
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getChurnkeyAuth: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const featureFlags = vi.hoisted(() => ({ churnkeyAppId: '' }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: featureFlags })
|
||||
}))
|
||||
|
||||
const { workspaceApi } = await import('@/platform/workspace/api/workspaceApi')
|
||||
const { isChurnkeyConfigured, prepareChurnkey } =
|
||||
await import('./churnkeyClient')
|
||||
|
||||
const getChurnkeyAuth = vi.mocked(workspaceApi.getChurnkeyAuth)
|
||||
|
||||
type ChurnkeyInit = NonNullable<ChurnkeyWindow['init']>
|
||||
|
||||
const AUTH_RESPONSE = {
|
||||
customer_id: 'cus_123',
|
||||
auth_hash: 'hash_abc',
|
||||
mode: 'test'
|
||||
} as const
|
||||
|
||||
describe('churnkeyClient', () => {
|
||||
beforeEach(() => {
|
||||
featureFlags.churnkeyAppId = 'app-test-123'
|
||||
getChurnkeyAuth.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.churnkey
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports isConfigured=false when the churnkey_app_id flag is unset', () => {
|
||||
featureFlags.churnkeyAppId = ''
|
||||
expect(isChurnkeyConfigured()).toBe(false)
|
||||
})
|
||||
|
||||
it('reports isConfigured=true when the churnkey_app_id flag is set', () => {
|
||||
featureFlags.churnkeyAppId = 'app-from-flag'
|
||||
expect(isChurnkeyConfigured()).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the churnkey_app_id flag is unset', async () => {
|
||||
featureFlags.churnkeyAppId = ''
|
||||
await expect(prepareChurnkey()).rejects.toThrow(
|
||||
'Churnkey is not configured'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects with ChurnkeyAuthUnavailableError when getChurnkeyAuth returns null', async () => {
|
||||
window.churnkey = { init: vi.fn<ChurnkeyInit>() }
|
||||
getChurnkeyAuth.mockResolvedValue(null)
|
||||
|
||||
await expect(prepareChurnkey()).rejects.toBeInstanceOf(
|
||||
ChurnkeyAuthUnavailableError
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the dev auth override instead of the backend endpoint when set', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
window.churnkey = { init }
|
||||
const windowWithAuth = window as {
|
||||
__CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse
|
||||
}
|
||||
windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__ = {
|
||||
customer_id: 'cus_dev',
|
||||
auth_hash: 'dev-hash',
|
||||
mode: 'sandbox'
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await prepareChurnkey()
|
||||
void session.show({})
|
||||
|
||||
expect(getChurnkeyAuth).not.toHaveBeenCalled()
|
||||
expect(init.mock.calls[0][1]).toMatchObject({
|
||||
customerId: 'cus_dev',
|
||||
authHash: 'dev-hash',
|
||||
mode: 'sandbox'
|
||||
})
|
||||
} finally {
|
||||
delete windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards customer credentials and provider config to churnkey.init', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
window.churnkey = { init }
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const onCancel = vi.fn()
|
||||
const session = await prepareChurnkey()
|
||||
const shown = session.show({
|
||||
onCancel,
|
||||
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
|
||||
})
|
||||
|
||||
expect(init).toHaveBeenCalledTimes(1)
|
||||
const [action, config] = init.mock.calls[0]
|
||||
expect(action).toBe('show')
|
||||
expect(config).toMatchObject({
|
||||
appId: 'app-test-123',
|
||||
authHash: 'hash_abc',
|
||||
customerId: 'cus_123',
|
||||
provider: 'stripe',
|
||||
mode: 'test',
|
||||
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
|
||||
})
|
||||
// No handleCancel - Churnkey handles the Stripe cancellation itself.
|
||||
expect(config.handleCancel).toBeUndefined()
|
||||
|
||||
config.onCancel?.('cus_123', 'too_expensive')
|
||||
expect(onCancel).toHaveBeenCalledWith('too_expensive')
|
||||
|
||||
config.onClose?.({ status: 'closed' })
|
||||
await expect(shown).resolves.toEqual({ status: 'closed' })
|
||||
})
|
||||
|
||||
it('adapts handleCancel to drop the customer argument', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
window.churnkey = { init }
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const handleCancel = vi.fn(async () => ({ message: 'ok' }))
|
||||
const session = await prepareChurnkey()
|
||||
void session.show({ handleCancel })
|
||||
|
||||
const [, config] = init.mock.calls[0]
|
||||
await config.handleCancel?.('cus_123', 'too_expensive', 'feedback')
|
||||
expect(handleCancel).toHaveBeenCalledWith('too_expensive', 'feedback')
|
||||
})
|
||||
|
||||
it('clears Churnkey session state when the modal closes', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
const clearState = vi.fn()
|
||||
window.churnkey = { init, clearState }
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const session = await prepareChurnkey()
|
||||
const shown = session.show({})
|
||||
|
||||
init.mock.calls[0][1].onClose?.({ status: 'closed' })
|
||||
await shown
|
||||
expect(clearState).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects show() when churnkey.init throws', async () => {
|
||||
window.churnkey = {
|
||||
init: vi.fn<ChurnkeyInit>(() => {
|
||||
throw new Error('init exploded')
|
||||
})
|
||||
}
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const session = await prepareChurnkey()
|
||||
await expect(session.show({})).rejects.toThrow('init exploded')
|
||||
})
|
||||
|
||||
it('passes the churnkey_app_id flag value as the init config appId', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
window.churnkey = { init }
|
||||
featureFlags.churnkeyAppId = 'app-from-flag'
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const session = await prepareChurnkey()
|
||||
void session.show({})
|
||||
|
||||
expect(init.mock.calls[0][1]).toMatchObject({ appId: 'app-from-flag' })
|
||||
})
|
||||
|
||||
describe('embed script loading', () => {
|
||||
function interceptInjectedScripts(): HTMLScriptElement[] {
|
||||
const scripts: HTMLScriptElement[] = []
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
scripts.push(...(nodes as HTMLScriptElement[]))
|
||||
})
|
||||
return scripts
|
||||
}
|
||||
|
||||
it('rejects with ChurnkeyEmbedLoadError when the script fails to load', async () => {
|
||||
const scripts = interceptInjectedScripts()
|
||||
|
||||
const prepare = prepareChurnkey()
|
||||
expect(scripts).toHaveLength(1)
|
||||
scripts[0].onerror?.(new Event('error'))
|
||||
|
||||
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
|
||||
expect(getChurnkeyAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries the script load on the next launch after a failure', async () => {
|
||||
const scripts = interceptInjectedScripts()
|
||||
|
||||
const first = prepareChurnkey()
|
||||
scripts[0].onerror?.(new Event('error'))
|
||||
await expect(first).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
|
||||
|
||||
const second = prepareChurnkey()
|
||||
expect(scripts).toHaveLength(2)
|
||||
scripts[1].onerror?.(new Event('error'))
|
||||
await expect(second).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
|
||||
})
|
||||
|
||||
it('rejects with ChurnkeyEmbedLoadError when the script loads without defining init', async () => {
|
||||
const scripts = interceptInjectedScripts()
|
||||
|
||||
const prepare = prepareChurnkey()
|
||||
scripts[0].onload?.call(scripts[0], new Event('load'))
|
||||
|
||||
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
|
||||
})
|
||||
|
||||
it('proceeds to auth once the loaded script provides init', async () => {
|
||||
const scripts = interceptInjectedScripts()
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const prepare = prepareChurnkey()
|
||||
expect(scripts[0].src).toContain('appId=app-test-123')
|
||||
window.churnkey!.init = vi.fn<ChurnkeyInit>()
|
||||
scripts[0].onload?.call(scripts[0], new Event('load'))
|
||||
|
||||
const session = await prepare
|
||||
expect(getChurnkeyAuth).toHaveBeenCalledTimes(1)
|
||||
expect(session.show).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/platform/cloud/churnkey/churnkeyClient.ts
Normal file
140
src/platform/cloud/churnkey/churnkeyClient.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import './embed-theme.css'
|
||||
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
|
||||
import type {
|
||||
ChurnkeyHandlerResult,
|
||||
ChurnkeyInitConfig,
|
||||
ChurnkeySessionResults
|
||||
} from './types'
|
||||
|
||||
const EMBED_SCRIPT_URL = 'https://assets.churnkey.co/js/app.js'
|
||||
|
||||
function readAppId(): string {
|
||||
return useFeatureFlags().flags.churnkeyAppId
|
||||
}
|
||||
|
||||
function readAuthOverride(): ChurnkeyAuthResponse | null {
|
||||
// Dev-only manual-testing hook: set `window.__CHURNKEY_AUTH_OVERRIDE__` to
|
||||
// exercise the embed before the backend `/billing/churnkey/auth` endpoint
|
||||
// is deployed. It forges credentials, so it is gated to dev and stripped
|
||||
// from production builds via import.meta.env.DEV tree-shaking.
|
||||
if (!import.meta.env.DEV) return null
|
||||
return (
|
||||
(window as { __CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse })
|
||||
.__CHURNKEY_AUTH_OVERRIDE__ ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function isChurnkeyConfigured(): boolean {
|
||||
return !!readAppId()
|
||||
}
|
||||
|
||||
let embedScriptPromise: Promise<void> | null = null
|
||||
|
||||
function injectEmbedScript(appId: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
window.churnkey ??= { created: true }
|
||||
const script = document.createElement('script')
|
||||
script.src = `${EMBED_SCRIPT_URL}?appId=${encodeURIComponent(appId)}`
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.churnkey?.init) resolve()
|
||||
else reject(new ChurnkeyEmbedLoadError())
|
||||
}
|
||||
script.onerror = () => {
|
||||
script.remove()
|
||||
reject(new ChurnkeyEmbedLoadError())
|
||||
}
|
||||
document.head.append(script)
|
||||
})
|
||||
}
|
||||
|
||||
function loadEmbedScript(appId: string): Promise<void> {
|
||||
if (window.churnkey?.init) return Promise.resolve()
|
||||
embedScriptPromise ??= injectEmbedScript(appId).catch((err: unknown) => {
|
||||
// Clear the cached attempt so the next launch can retry the load.
|
||||
embedScriptPromise = null
|
||||
throw err
|
||||
})
|
||||
return embedScriptPromise
|
||||
}
|
||||
|
||||
interface ChurnkeyShowOptions {
|
||||
handleCancel?: (
|
||||
surveyResponse: string,
|
||||
freeformFeedback?: string
|
||||
) => Promise<ChurnkeyHandlerResult>
|
||||
onCancel?: (surveyResponse: string) => void
|
||||
customerAttributes?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface ChurnkeySession {
|
||||
/**
|
||||
* Opens the Churnkey modal. Resolves with the session results when the
|
||||
* modal closes; rejects only if `churnkey.init` itself throws.
|
||||
*/
|
||||
show: (options: ChurnkeyShowOptions) => Promise<ChurnkeySessionResults>
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the Churnkey embed script (on demand, cached) and fetches signed
|
||||
* auth credentials. Throws {@link ChurnkeyEmbedLoadError} or
|
||||
* {@link ChurnkeyAuthUnavailableError} so callers can fall back to the
|
||||
* legacy cancel dialog before any cancellation-funnel telemetry fires.
|
||||
*/
|
||||
export async function prepareChurnkey(): Promise<ChurnkeySession> {
|
||||
const appId = readAppId()
|
||||
if (!appId) {
|
||||
throw new Error(
|
||||
'Churnkey is not configured (churnkey_app_id flag is unset)'
|
||||
)
|
||||
}
|
||||
|
||||
await loadEmbedScript(appId)
|
||||
const init = window.churnkey?.init
|
||||
if (!init) throw new ChurnkeyEmbedLoadError()
|
||||
|
||||
const override = readAuthOverride()
|
||||
const auth = override ?? (await workspaceApi.getChurnkeyAuth())
|
||||
if (auth === null) {
|
||||
throw new ChurnkeyAuthUnavailableError()
|
||||
}
|
||||
|
||||
// Arrow assignment (not a hoisted declaration) so the narrowing of
|
||||
// `init` and `auth` above carries into the closure.
|
||||
const show = (options: ChurnkeyShowOptions) =>
|
||||
new Promise<ChurnkeySessionResults>((resolve, reject) => {
|
||||
const config: ChurnkeyInitConfig = {
|
||||
appId,
|
||||
authHash: auth.auth_hash,
|
||||
customerId: auth.customer_id,
|
||||
provider: 'stripe',
|
||||
mode: auth.mode,
|
||||
record: true,
|
||||
customerAttributes: options.customerAttributes,
|
||||
onCancel: (_customer, surveyResponse) =>
|
||||
options.onCancel?.(surveyResponse),
|
||||
onClose: (results) => {
|
||||
// Reset Churnkey's cached session state so the next launch
|
||||
// restarts at step 1 (e.g. user visited Stripe but did not cancel).
|
||||
window.churnkey?.clearState?.()
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
if (options.handleCancel) {
|
||||
const userHandleCancel = options.handleCancel
|
||||
config.handleCancel = (_customer, surveyResponse, freeformFeedback) =>
|
||||
userHandleCancel(surveyResponse, freeformFeedback)
|
||||
}
|
||||
try {
|
||||
init('show', config)
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
|
||||
return { show }
|
||||
}
|
||||
199
src/platform/cloud/churnkey/embed-theme.css
Normal file
199
src/platform/cloud/churnkey/embed-theme.css
Normal file
@@ -0,0 +1,199 @@
|
||||
#ck-app .ck-modal-container {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
.ck-style,
|
||||
.ck-style * {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.ck-background-overlay,
|
||||
#ck-cf-modal-overlay {
|
||||
background: rgb(0 0 0 / 0.7) !important;
|
||||
}
|
||||
|
||||
/* Churnkey uses var(--color-brand-black) for primary text (titles, etc.).
|
||||
Remap it to our light foreground so that text is readable on the dark
|
||||
modal. Background utilities that also use it (bg-brand-black) are guarded
|
||||
below so they don't turn light. */
|
||||
#ck-app,
|
||||
.ck-style {
|
||||
--color-brand-black: var(--base-foreground) !important;
|
||||
}
|
||||
|
||||
.ck-modal,
|
||||
#ck-cf-modal {
|
||||
background: var(--base-background) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.ck-step,
|
||||
.ck-survey-step,
|
||||
.ck-confirm-step,
|
||||
.ck-freeform-step,
|
||||
.ck-pause-step,
|
||||
.ck-discount-step,
|
||||
.ck-contact-step,
|
||||
.ck-redirect-step,
|
||||
.ck-complete-step,
|
||||
.ck-progress-step,
|
||||
.ck-error-step {
|
||||
background: var(--base-background) !important;
|
||||
}
|
||||
|
||||
.ck-step-header {
|
||||
background: var(--base-background) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
.ck-step-header-text {
|
||||
color: var(--base-foreground) !important;
|
||||
}
|
||||
.ck-step-description-text,
|
||||
.ck-description,
|
||||
.ck-style .subtitle {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
.ck-step-body {
|
||||
background: var(--base-background) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
.ck-step-footer {
|
||||
background: var(--base-background) !important;
|
||||
border-top: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.ck-style select,
|
||||
.ck-style input,
|
||||
.ck-style textarea {
|
||||
background-color: var(--secondary-background) !important;
|
||||
color: var(--base-foreground) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
.ck-style select:focus-visible,
|
||||
.ck-style input:focus-visible,
|
||||
.ck-style textarea:focus-visible {
|
||||
border-color: var(--primary-background) !important;
|
||||
box-shadow: 0 0 0 2px var(--primary-background) !important;
|
||||
outline: 2px solid transparent !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
.ck-style ::placeholder {
|
||||
color: var(--muted-foreground) !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
.ck-style option {
|
||||
background-color: var(--secondary-background) !important;
|
||||
color: var(--base-foreground) !important;
|
||||
}
|
||||
|
||||
/* Churnkey injects its compiled utility CSS at runtime, AFTER this bundled
|
||||
sheet, so `.ck-style`-scoped overrides tie on specificity and lose on
|
||||
source order — leaving dark brand/gray text on the dark modal. The
|
||||
`#ck-app` id prefix raises specificity above Churnkey's `.ck-style`
|
||||
utilities so these win regardless of injection order. */
|
||||
#ck-app .text-gray-900,
|
||||
#ck-app .text-gray-800,
|
||||
#ck-app .text-brand-black {
|
||||
color: var(--base-foreground) !important;
|
||||
}
|
||||
#ck-app .text-gray-700,
|
||||
#ck-app .text-gray-600,
|
||||
#ck-app .text-gray-500,
|
||||
#ck-app .text-gray-400 {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
#ck-app .border-gray-100,
|
||||
#ck-app .border-gray-200,
|
||||
#ck-app .border-gray-300 {
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
#ck-app .bg-gray-100,
|
||||
#ck-app .bg-gray-200,
|
||||
#ck-app .bg-gray-300 {
|
||||
background-color: var(--secondary-background) !important;
|
||||
}
|
||||
/* Guard: brand-black is remapped to a light foreground for text, so force
|
||||
its background usage to a dark surface. Primary buttons override this to
|
||||
the accent via their own rule below. */
|
||||
#ck-app .bg-brand-black {
|
||||
background-color: var(--secondary-background) !important;
|
||||
}
|
||||
#ck-app .text-opacity-60,
|
||||
#ck-app .text-opacity-80,
|
||||
#ck-app .text-opacity-90 {
|
||||
--tw-text-opacity: 1 !important;
|
||||
}
|
||||
|
||||
#ck-app [class*='bg-client-primary-light'] {
|
||||
background-color: var(--secondary-background) !important;
|
||||
}
|
||||
#ck-app .bg-opacity-5 {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
}
|
||||
.ck-pause-subscription-details {
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
.ck-pause-subscription-details b {
|
||||
color: var(--base-foreground) !important;
|
||||
}
|
||||
#ck-app .active-discount-disclaimer {
|
||||
color: var(--muted-foreground) !important;
|
||||
background-color: var(--secondary-background) !important;
|
||||
}
|
||||
.ck-step-body li,
|
||||
.ck-step-body label {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
#ck-app .h-14.rounded-t-lg {
|
||||
background-color: var(--secondary-background) !important;
|
||||
}
|
||||
#ck-app .bg-client-primary {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
#ck-app .text-client-primary,
|
||||
#ck-app .text-client-primary-light {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
#ck-app .text-client-primary-middle {
|
||||
color: var(--muted-foreground) !important;
|
||||
opacity: 0.4 !important;
|
||||
}
|
||||
#ck-app .border-client-primary {
|
||||
border-color: var(--primary-background) !important;
|
||||
}
|
||||
#ck-app .border-client-primary-light,
|
||||
#ck-app .border-text-client-primary {
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
|
||||
/* Buttons carry both Churnkey component classes (.ck-*-button) and raw
|
||||
utilities (bg-brand-black, bg-gray-200, text-white, text-brand-black).
|
||||
Scope under the #ck-app id so these win over Churnkey's runtime-injected
|
||||
`.ck-style` utilities — otherwise the utility bg/text colors leak through
|
||||
and produce light-on-light / dark-on-dark buttons. */
|
||||
#ck-app .ck-primary-button,
|
||||
#ck-app .ck-black-primary-button {
|
||||
background: var(--primary-background) !important;
|
||||
color: var(--base-foreground) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
#ck-app .ck-primary-button:hover,
|
||||
#ck-app .ck-black-primary-button:hover {
|
||||
background: var(--primary-background-hover) !important;
|
||||
}
|
||||
#ck-app .ck-gray-primary-button {
|
||||
background: var(--secondary-background) !important;
|
||||
color: var(--base-foreground) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
#ck-app .ck-text-button,
|
||||
#ck-app .ck-black-text-button {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
25
src/platform/cloud/churnkey/errors.ts
Normal file
25
src/platform/cloud/churnkey/errors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Thrown when the backend's `/billing/churnkey/auth` endpoint is missing
|
||||
* (e.g. backend hasn't been deployed yet). Callers should treat this the
|
||||
* same as Churnkey not being configured at all and fall back to the
|
||||
* legacy cancel dialog rather than surfacing a toast.
|
||||
*/
|
||||
export class ChurnkeyAuthUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Churnkey auth endpoint not available')
|
||||
this.name = 'ChurnkeyAuthUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the Churnkey embed script fails to load — network failure or,
|
||||
* more likely, an ad blocker (churn-prevention scripts are on common
|
||||
* blocklists). Callers must fall back to the legacy cancel dialog so the
|
||||
* user always has a way to cancel.
|
||||
*/
|
||||
export class ChurnkeyEmbedLoadError extends Error {
|
||||
constructor() {
|
||||
super('Churnkey embed script failed to load')
|
||||
this.name = 'ChurnkeyEmbedLoadError'
|
||||
}
|
||||
}
|
||||
394
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal file
394
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchStatus: vi.fn(),
|
||||
cancelSubscription: vi.fn(),
|
||||
trackCancellationFlowOpened: vi.fn(),
|
||||
trackCancellationFlowClosed: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn(),
|
||||
toastAdd: vi.fn(),
|
||||
prepareChurnkey: vi.fn(),
|
||||
show: vi.fn(),
|
||||
billingType: { value: 'workspace' as 'legacy' | 'workspace' },
|
||||
subscription: {
|
||||
value: null as {
|
||||
tier: string | null
|
||||
duration: string | null
|
||||
planSlug: string | null
|
||||
} | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mocks.toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
type: {
|
||||
get value() {
|
||||
return mocks.billingType.value
|
||||
}
|
||||
},
|
||||
fetchStatus: mocks.fetchStatus,
|
||||
cancelSubscription: mocks.cancelSubscription,
|
||||
subscription: {
|
||||
get value() {
|
||||
return mocks.subscription.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackCancellationFlowOpened: mocks.trackCancellationFlowOpened,
|
||||
trackCancellationFlowClosed: mocks.trackCancellationFlowClosed,
|
||||
trackMonthlySubscriptionCancelled: mocks.trackMonthlySubscriptionCancelled
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./churnkeyClient', () => ({
|
||||
prepareChurnkey: mocks.prepareChurnkey
|
||||
}))
|
||||
|
||||
const { launchChurnkeyCancellation } =
|
||||
await import('./launchChurnkeyCancellation')
|
||||
|
||||
interface CapturedShowOptions {
|
||||
customerAttributes?: Record<string, string>
|
||||
handleCancel?: () => Promise<{ message?: string }>
|
||||
onCancel: (surveyResponse: string) => void
|
||||
}
|
||||
|
||||
type SessionResults = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Mirrors the real client contract: show() captures the session callbacks
|
||||
* and resolves with the session results when the modal closes.
|
||||
*/
|
||||
function openDeferredSession() {
|
||||
let resolveShow!: (results: SessionResults) => void
|
||||
let rejectShow!: (err: unknown) => void
|
||||
let options: CapturedShowOptions | undefined
|
||||
mocks.show.mockImplementation((opts: CapturedShowOptions) => {
|
||||
options = opts
|
||||
return new Promise<SessionResults>((resolve, reject) => {
|
||||
resolveShow = resolve
|
||||
rejectShow = reject
|
||||
})
|
||||
})
|
||||
return {
|
||||
options: () => {
|
||||
if (!options) throw new Error('churnkey session.show was not called')
|
||||
return options
|
||||
},
|
||||
close: (results: SessionResults) => resolveShow(results),
|
||||
fail: (err: unknown) => rejectShow(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForShow() {
|
||||
await vi.waitFor(() => expect(mocks.show).toHaveBeenCalled())
|
||||
}
|
||||
|
||||
describe('launchChurnkeyCancellation', () => {
|
||||
beforeEach(() => {
|
||||
mocks.billingType.value = 'workspace'
|
||||
mocks.subscription.value = null
|
||||
mocks.prepareChurnkey.mockReset()
|
||||
mocks.prepareChurnkey.mockResolvedValue({ show: mocks.show })
|
||||
mocks.show.mockReset()
|
||||
mocks.show.mockResolvedValue({ status: 'closed' })
|
||||
mocks.fetchStatus.mockReset()
|
||||
mocks.fetchStatus.mockResolvedValue(undefined)
|
||||
mocks.cancelSubscription.mockReset()
|
||||
mocks.cancelSubscription.mockResolvedValue(undefined)
|
||||
mocks.trackCancellationFlowOpened.mockReset()
|
||||
mocks.trackCancellationFlowClosed.mockReset()
|
||||
mocks.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mocks.toastAdd.mockReset()
|
||||
})
|
||||
|
||||
it('emits exactly one cancellation_flow_closed when the user cancels', async () => {
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
session.options().onCancel('too_expensive')
|
||||
session.close({ status: 'canceled' })
|
||||
await launch
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'canceled',
|
||||
survey_response: 'too_expensive'
|
||||
})
|
||||
expect(mocks.trackMonthlySubscriptionCancelled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tracks opened once per session, after preparation succeeds', async () => {
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
|
||||
const prepareOrder = mocks.prepareChurnkey.mock.invocationCallOrder[0]
|
||||
const openedOrder =
|
||||
mocks.trackCancellationFlowOpened.mock.invocationCallOrder[0]
|
||||
const showOrder = mocks.show.mock.invocationCallOrder[0]
|
||||
expect(prepareOrder).toBeLessThan(openedOrder)
|
||||
expect(openedOrder).toBeLessThan(showOrder)
|
||||
})
|
||||
|
||||
it('passes handleCancel and calls billing.cancelSubscription for workspace billing', async () => {
|
||||
mocks.billingType.value = 'workspace'
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
const handleCancel = session.options().handleCancel
|
||||
expect(handleCancel).toBeTypeOf('function')
|
||||
await expect(handleCancel?.()).resolves.toEqual({
|
||||
message: 'subscription.cancelSuccess'
|
||||
})
|
||||
expect(mocks.cancelSubscription).toHaveBeenCalledTimes(1)
|
||||
|
||||
session.close({ status: 'canceled' })
|
||||
await launch
|
||||
})
|
||||
|
||||
it('omits handleCancel for legacy billing so Churnkey cancels via Stripe', async () => {
|
||||
mocks.billingType.value = 'legacy'
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
expect(session.options().handleCancel).toBeUndefined()
|
||||
expect(mocks.cancelSubscription).not.toHaveBeenCalled()
|
||||
|
||||
session.close({ status: 'closed' })
|
||||
await launch
|
||||
})
|
||||
|
||||
it('rejects handleCancel with the API error message and records cancel_api_failed on close', async () => {
|
||||
const apiError = new Error('card declined')
|
||||
mocks.cancelSubscription.mockRejectedValue(apiError)
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
// Churnkey shows this rejection message in its own UI.
|
||||
await expect(session.options().handleCancel?.()).rejects.toMatchObject({
|
||||
message: 'card declined',
|
||||
cause: apiError
|
||||
})
|
||||
|
||||
session.close({ status: 'closed' })
|
||||
await launch
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'unknown',
|
||||
failure_reason: 'cancel_api_failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the cancel_api_failed flag when a retry succeeds', async () => {
|
||||
mocks.cancelSubscription
|
||||
.mockRejectedValueOnce(new Error('card declined'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
const handleCancel = session.options().handleCancel
|
||||
await expect(handleCancel?.()).rejects.toThrow('card declined')
|
||||
await expect(handleCancel?.()).resolves.toEqual({
|
||||
message: 'subscription.cancelSuccess'
|
||||
})
|
||||
|
||||
session.options().onCancel('too_expensive')
|
||||
session.close({ status: 'canceled' })
|
||||
await launch
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'canceled',
|
||||
survey_response: 'too_expensive'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes local billing state after a cancel', async () => {
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
session.options().onCancel('too_expensive')
|
||||
session.close({ status: 'canceled' })
|
||||
await launch
|
||||
|
||||
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('does not refresh local state when the user closes without canceling', async () => {
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.fetchStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records reconsidered when the user closes without canceling', async () => {
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'reconsidered'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Churnkey discounted status to discounted outcome', async () => {
|
||||
mocks.show.mockResolvedValue({ status: 'discounted' })
|
||||
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'discounted'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Churnkey paused status to paused outcome', async () => {
|
||||
mocks.show.mockResolvedValue({ status: 'paused' })
|
||||
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'paused'
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows fetchStatus failures after the cancel', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue(new Error('network'))
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
session.options().onCancel('too_expensive')
|
||||
session.close({ status: 'canceled' })
|
||||
|
||||
await expect(launch).resolves.toBeUndefined()
|
||||
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'canceled',
|
||||
survey_response: 'too_expensive'
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards customerAttributes from billing subscription', async () => {
|
||||
mocks.subscription.value = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'pro-monthly'
|
||||
}
|
||||
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.show.mock.calls[0][0].customerAttributes).toEqual({
|
||||
tier: 'PRO',
|
||||
cycle: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits customerAttributes when subscription is null', async () => {
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
expect(mocks.show.mock.calls[0][0].customerAttributes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-throws ChurnkeyAuthUnavailableError without toast or telemetry', async () => {
|
||||
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyAuthUnavailableError())
|
||||
|
||||
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
|
||||
ChurnkeyAuthUnavailableError
|
||||
)
|
||||
expect(mocks.toastAdd).not.toHaveBeenCalled()
|
||||
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
|
||||
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-throws ChurnkeyEmbedLoadError without toast or telemetry', async () => {
|
||||
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyEmbedLoadError())
|
||||
|
||||
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
|
||||
ChurnkeyEmbedLoadError
|
||||
)
|
||||
expect(mocks.toastAdd).not.toHaveBeenCalled()
|
||||
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
|
||||
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a toast without telemetry when preparation fails unexpectedly', async () => {
|
||||
mocks.prepareChurnkey.mockRejectedValue(new Error('auth endpoint 500'))
|
||||
|
||||
await expect(launchChurnkeyCancellation()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'auth endpoint 500'
|
||||
})
|
||||
)
|
||||
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
|
||||
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a toast and a balancing closed event when the session fails after opening', async () => {
|
||||
const session = openDeferredSession()
|
||||
const launch = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
session.fail(new Error('init exploded'))
|
||||
await launch
|
||||
|
||||
expect(mocks.toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'init exploded'
|
||||
})
|
||||
)
|
||||
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
|
||||
outcome: 'unknown',
|
||||
failure_reason: 'unexpected'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores concurrent calls while the session is open', async () => {
|
||||
const session = openDeferredSession()
|
||||
const first = launchChurnkeyCancellation()
|
||||
await waitForShow()
|
||||
|
||||
await launchChurnkeyCancellation()
|
||||
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
|
||||
|
||||
session.close({ status: 'closed' })
|
||||
await first
|
||||
|
||||
// Guard released on close; a fresh launch proceeds.
|
||||
mocks.show.mockReset()
|
||||
mocks.show.mockResolvedValue({ status: 'closed' })
|
||||
await launchChurnkeyCancellation()
|
||||
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases the in-flight guard when preparation fails', async () => {
|
||||
mocks.prepareChurnkey.mockRejectedValueOnce(new Error('boom'))
|
||||
await launchChurnkeyCancellation()
|
||||
|
||||
await launchChurnkeyCancellation()
|
||||
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
143
src/platform/cloud/churnkey/launchChurnkeyCancellation.ts
Normal file
143
src/platform/cloud/churnkey/launchChurnkeyCancellation.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { CancellationFlowClosedMetadata } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type { ChurnkeySession } from './churnkeyClient'
|
||||
import { prepareChurnkey } from './churnkeyClient'
|
||||
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
|
||||
import type { ChurnkeySessionResults } from './types'
|
||||
|
||||
type CancellationOutcome = CancellationFlowClosedMetadata['outcome']
|
||||
|
||||
function deriveOutcome(
|
||||
results: ChurnkeySessionResults,
|
||||
canceledThisSession: boolean,
|
||||
cancelApiFailed: boolean
|
||||
): CancellationOutcome {
|
||||
if (canceledThisSession) return 'canceled'
|
||||
if (cancelApiFailed) return 'unknown'
|
||||
if (results.status === 'closed') return 'reconsidered'
|
||||
return results.status ?? 'unknown'
|
||||
}
|
||||
|
||||
function buildCustomerAttributes(
|
||||
billing: ReturnType<typeof useBillingContext>
|
||||
): Record<string, string> | undefined {
|
||||
const sub = billing.subscription.value
|
||||
if (!sub) return undefined
|
||||
const attrs: Record<string, string> = {}
|
||||
if (sub.tier) attrs.tier = sub.tier
|
||||
if (sub.duration) attrs.cycle = sub.duration
|
||||
if (sub.planSlug) attrs.plan_slug = sub.planSlug
|
||||
return Object.keys(attrs).length > 0 ? attrs : undefined
|
||||
}
|
||||
|
||||
let inFlight = false
|
||||
|
||||
export async function launchChurnkeyCancellation(): Promise<void> {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
await runCancellationFlow()
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runCancellationFlow(): Promise<void> {
|
||||
const billing = useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToastStore()
|
||||
|
||||
function showFailureToast(err: unknown) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
let session: ChurnkeySession
|
||||
try {
|
||||
session = await prepareChurnkey()
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ChurnkeyAuthUnavailableError ||
|
||||
err instanceof ChurnkeyEmbedLoadError
|
||||
) {
|
||||
// Re-throw so the caller can route to the legacy dialog.
|
||||
throw err
|
||||
}
|
||||
showFailureToast(err)
|
||||
return
|
||||
}
|
||||
|
||||
let canceledThisSession = false
|
||||
let cancelApiFailed = false
|
||||
let lastSurveyResponse: string | undefined
|
||||
|
||||
telemetry?.trackCancellationFlowOpened()
|
||||
|
||||
try {
|
||||
const results = await session.show({
|
||||
customerAttributes: buildCustomerAttributes(billing),
|
||||
// Workspace billing cancels through our API; legacy billing omits
|
||||
// handleCancel so Churnkey cancels directly via Stripe.
|
||||
...(billing.type.value === 'workspace' && {
|
||||
handleCancel: async () => {
|
||||
try {
|
||||
await billing.cancelSubscription()
|
||||
} catch (err) {
|
||||
cancelApiFailed = true
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('subscription.cancelDialog.failed')
|
||||
// Churnkey displays the rejection message in its own UI.
|
||||
throw new Error(message, { cause: err })
|
||||
}
|
||||
cancelApiFailed = false
|
||||
return { message: t('subscription.cancelSuccess') }
|
||||
}
|
||||
}),
|
||||
// Fires after a successful cancel — whether via handleCancel (team)
|
||||
// or Churnkey's own Stripe cancel (legacy). No double-fire with
|
||||
// useSubscriptionCancellationWatcher: that watcher only runs after
|
||||
// opening the Stripe billing portal via manageSubscription.
|
||||
onCancel: (surveyResponse) => {
|
||||
canceledThisSession = true
|
||||
lastSurveyResponse = surveyResponse
|
||||
telemetry?.trackMonthlySubscriptionCancelled()
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = deriveOutcome(results, canceledThisSession, cancelApiFailed)
|
||||
const failureReason = cancelApiFailed
|
||||
? ('cancel_api_failed' as const)
|
||||
: undefined
|
||||
telemetry?.trackCancellationFlowClosed({
|
||||
outcome,
|
||||
...(lastSurveyResponse !== undefined && {
|
||||
survey_response: lastSurveyResponse
|
||||
}),
|
||||
...(failureReason !== undefined && { failure_reason: failureReason })
|
||||
})
|
||||
|
||||
if (canceledThisSession) {
|
||||
// Refresh local state so the UI reflects the cancellation. Failure
|
||||
// here is non-blocking; the next page load will catch up.
|
||||
void billing.fetchStatus().catch(() => {})
|
||||
}
|
||||
} catch (err) {
|
||||
// session.show only rejects when churnkey.init itself throws — keep
|
||||
// the funnel balanced since `opened` has already been tracked.
|
||||
telemetry?.trackCancellationFlowClosed({
|
||||
outcome: 'unknown',
|
||||
failure_reason: 'unexpected'
|
||||
})
|
||||
showFailureToast(err)
|
||||
}
|
||||
}
|
||||
55
src/platform/cloud/churnkey/types.ts
Normal file
55
src/platform/cloud/churnkey/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Subset of the Churnkey embed API. No official @types package exists.
|
||||
// Docs: https://docs.churnkey.co/cancel-flows/further-configuration/
|
||||
|
||||
export type ChurnkeyMode = 'live' | 'test' | 'sandbox'
|
||||
|
||||
type ChurnkeyProvider = 'stripe' | 'chargebee' | 'braintree' | 'paddle'
|
||||
|
||||
export interface ChurnkeyHandlerResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ChurnkeyInitConfig {
|
||||
appId: string
|
||||
authHash: string
|
||||
customerId: string
|
||||
subscriptionId?: string
|
||||
provider: ChurnkeyProvider
|
||||
mode: ChurnkeyMode
|
||||
record?: boolean
|
||||
preview?: boolean
|
||||
report?: boolean
|
||||
bypassDiscountAppliedScreen?: boolean
|
||||
bypassPauseAppliedScreen?: boolean
|
||||
customerAttributes?: Record<string, string | number>
|
||||
|
||||
handleCancel?: (
|
||||
customer: string,
|
||||
surveyResponse: string,
|
||||
freeformFeedback?: string
|
||||
) => Promise<ChurnkeyHandlerResult>
|
||||
handleSupportRequest?: (customer: string) => void
|
||||
|
||||
onCancel?: (customer: string, surveyResponse: string) => void
|
||||
onClose?: (sessionResults: ChurnkeySessionResults) => void
|
||||
onGoToAccount?: (sessionResults: ChurnkeySessionResults) => void
|
||||
}
|
||||
|
||||
export interface ChurnkeySessionResults {
|
||||
status?: 'canceled' | 'discounted' | 'paused' | 'closed'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ChurnkeyWindow {
|
||||
created?: boolean
|
||||
/** Defined once the embed script (loaded on demand) has executed. */
|
||||
init?: (action: 'show' | 'restart', config: ChurnkeyInitConfig) => void
|
||||
hide?: () => void
|
||||
clearState?: () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
churnkey?: ChurnkeyWindow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
ChurnkeyAuthUnavailableError,
|
||||
ChurnkeyEmbedLoadError
|
||||
} from '@/platform/cloud/churnkey/errors'
|
||||
|
||||
const showCancelSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
const launchChurnkeyCancellationMock = vi.hoisted(() => vi.fn())
|
||||
const isChurnkeyConfiguredMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./showCancelSubscriptionDialog', () => ({
|
||||
showCancelSubscriptionDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/churnkey/churnkeyClient', () => ({
|
||||
isChurnkeyConfigured: isChurnkeyConfiguredMock
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/churnkey/launchChurnkeyCancellation', () => ({
|
||||
launchChurnkeyCancellation: launchChurnkeyCancellationMock
|
||||
}))
|
||||
|
||||
const { launchCancellationFlow } = await import('./launchCancellationFlow')
|
||||
|
||||
describe('launchCancellationFlow', () => {
|
||||
beforeEach(() => {
|
||||
showCancelSubscriptionDialog.mockReset()
|
||||
launchChurnkeyCancellationMock.mockReset()
|
||||
isChurnkeyConfiguredMock.mockReset()
|
||||
})
|
||||
|
||||
it('launches Churnkey when the churnkey_app_id flag is set', async () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(true)
|
||||
launchChurnkeyCancellationMock.mockResolvedValue(undefined)
|
||||
|
||||
await launchCancellationFlow('2026-12-01')
|
||||
|
||||
expect(launchChurnkeyCancellationMock).toHaveBeenCalledTimes(1)
|
||||
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the legacy dialog when the churnkey_app_id flag is not set', async () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(false)
|
||||
|
||||
await launchCancellationFlow('2026-12-01')
|
||||
|
||||
expect(launchChurnkeyCancellationMock).not.toHaveBeenCalled()
|
||||
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
|
||||
})
|
||||
|
||||
it('falls back to the legacy dialog on ChurnkeyAuthUnavailableError', async () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(true)
|
||||
launchChurnkeyCancellationMock.mockRejectedValue(
|
||||
new ChurnkeyAuthUnavailableError()
|
||||
)
|
||||
|
||||
await launchCancellationFlow('2026-12-01')
|
||||
|
||||
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
|
||||
})
|
||||
|
||||
it('falls back to the legacy dialog when the embed script fails to load', async () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(true)
|
||||
launchChurnkeyCancellationMock.mockRejectedValue(
|
||||
new ChurnkeyEmbedLoadError()
|
||||
)
|
||||
|
||||
await launchCancellationFlow('2026-12-01')
|
||||
|
||||
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
|
||||
})
|
||||
|
||||
it('does not fall back when Churnkey throws other errors', async () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(true)
|
||||
launchChurnkeyCancellationMock.mockRejectedValue(
|
||||
new Error('something else')
|
||||
)
|
||||
|
||||
await expect(launchCancellationFlow('2026-12-01')).rejects.toThrow(
|
||||
'something else'
|
||||
)
|
||||
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
39
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
39
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { isChurnkeyConfigured } from '@/platform/cloud/churnkey/churnkeyClient'
|
||||
import {
|
||||
ChurnkeyAuthUnavailableError,
|
||||
ChurnkeyEmbedLoadError
|
||||
} from '@/platform/cloud/churnkey/errors'
|
||||
import { launchChurnkeyCancellation } from '@/platform/cloud/churnkey/launchChurnkeyCancellation'
|
||||
|
||||
import { showCancelSubscriptionDialog } from './showCancelSubscriptionDialog'
|
||||
|
||||
function shouldUseChurnkey(): boolean {
|
||||
if (isChurnkeyConfigured()) return true
|
||||
console.info(
|
||||
'[Churnkey] Using legacy cancel dialog: churnkey_app_id flag is not set.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function launchCancellationFlow(cancelAt?: string): Promise<void> {
|
||||
if (!shouldUseChurnkey()) {
|
||||
await showCancelSubscriptionDialog(cancelAt)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await launchChurnkeyCancellation()
|
||||
} catch (err) {
|
||||
const fallbackReason =
|
||||
err instanceof ChurnkeyAuthUnavailableError
|
||||
? 'auth endpoint unavailable'
|
||||
: err instanceof ChurnkeyEmbedLoadError
|
||||
? 'embed script failed to load (often blocked by an ad blocker)'
|
||||
: null
|
||||
if (fallbackReason === null) throw err
|
||||
console.warn(
|
||||
`[Churnkey] Falling back to legacy cancel dialog: ${fallbackReason}.`
|
||||
)
|
||||
await showCancelSubscriptionDialog(cancelAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
export async function showCancelSubscriptionDialog(cancelAt?: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
|
||||
return useDialogStore().showDialog({
|
||||
key: 'cancel-subscription',
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export type RemoteConfig = {
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
churnkey_app_id?: string
|
||||
unified_cloud_auth?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
|
||||
@@ -9,6 +9,13 @@ 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', () => ({
|
||||
@@ -33,7 +40,7 @@ const FormItemUpdateStub = defineComponent({
|
||||
template: '<div data-testid="form-item-stub" />'
|
||||
})
|
||||
|
||||
describe('SettingItem', () => {
|
||||
describe('SettingItem (telemetry UI tracking)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
emitFormValue = null
|
||||
@@ -54,15 +61,15 @@ describe('SettingItem', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('persists setting updates through the setting store', async () => {
|
||||
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
|
||||
const settingParams: SettingParams = {
|
||||
id: 'main.sub.setting.name',
|
||||
name: 'Visible Setting',
|
||||
name: 'Telemetry Visible',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
}
|
||||
|
||||
mockGet.mockReturnValue('default')
|
||||
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
|
||||
mockSet.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent(settingParams)
|
||||
@@ -71,6 +78,33 @@ describe('SettingItem', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ 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'
|
||||
|
||||
@@ -80,6 +81,19 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
|
||||
const updateSettingValue = async <K extends keyof Settings>(
|
||||
newValue: Settings[K]
|
||||
) => {
|
||||
await settingStore.set(props.setting.id, newValue)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -945,7 +945,6 @@ 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,16 +11,6 @@ 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: {
|
||||
@@ -409,6 +399,11 @@ 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')
|
||||
@@ -420,120 +415,6 @@ 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 = {
|
||||
@@ -661,34 +542,6 @@ 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',
|
||||
@@ -728,7 +581,6 @@ describe('useSettingStore', () => {
|
||||
await store.setMany({ 'Comfy.Release.Version': 'existing' })
|
||||
|
||||
expect(api.storeSettings).not.toHaveBeenCalled()
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ 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'
|
||||
@@ -25,11 +23,6 @@ export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
interface AppliedSetting<TValue> {
|
||||
previousValue: TValue
|
||||
newValue: TValue
|
||||
}
|
||||
|
||||
function tryMigrateDeprecatedValue(
|
||||
setting: SettingParams | undefined,
|
||||
value: unknown
|
||||
@@ -52,28 +45,6 @@ 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>>({})
|
||||
@@ -128,7 +99,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
function applySettingLocally<K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K]
|
||||
): AppliedSetting<Settings[K]> | undefined {
|
||||
): Settings[K] | undefined {
|
||||
const clonedValue = _.cloneDeep(value)
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
@@ -138,12 +109,8 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
if (newValue === oldValue) return undefined
|
||||
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
const typedNewValue = newValue as Settings[K]
|
||||
settingValues.value[key] = typedNewValue
|
||||
return {
|
||||
previousValue: oldValue,
|
||||
newValue: typedNewValue
|
||||
}
|
||||
settingValues.value[key] = newValue
|
||||
return newValue as Settings[K]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,10 +121,7 @@ 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.newValue)
|
||||
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) useTelemetry()?.trackSettingChanged(event)
|
||||
await api.storeSetting(key, applied)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +130,6 @@ 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(
|
||||
@@ -174,18 +137,12 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
settings[key] as Settings[typeof key]
|
||||
)
|
||||
if (applied !== undefined) {
|
||||
updatedSettings[key] = applied.newValue
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) telemetryEvents.push(event)
|
||||
updatedSettings[key] = applied
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatedSettings).length > 0) {
|
||||
await api.storeSettings(updatedSettings)
|
||||
const telemetry = useTelemetry()
|
||||
for (const event of telemetryEvents) {
|
||||
telemetry?.trackSettingChanged(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,22 +26,11 @@ 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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
CancellationFlowClosedMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -268,4 +269,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
|
||||
trackCancellationFlowOpened(): void {
|
||||
this.dispatch((provider) => provider.trackCancellationFlowOpened?.())
|
||||
}
|
||||
|
||||
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackCancellationFlowClosed?.(metadata)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
@@ -488,6 +488,54 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancellation flow', () => {
|
||||
it('stamps the reconsidered person property when the flow closes reconsidered', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
|
||||
{ outcome: 'reconsidered' }
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
|
||||
cancellation_reconsidered_at: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not stamp the person property for other outcomes', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackCancellationFlowClosed({ outcome: 'canceled' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
|
||||
{ outcome: 'canceled' }
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stamp the person property when the closed event is disabled', async () => {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
telemetry_disabled_events: [TelemetryEvents.CANCELLATION_FLOW_CLOSED]
|
||||
}
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
|
||||
expect.anything()
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).not.toHaveBeenCalledWith({
|
||||
cancellation_reconsidered_at: expect.any(String)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled events', () => {
|
||||
it('does not capture default disabled events', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -635,7 +683,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures legacy page view event with page_name property', async () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -645,13 +693,9 @@ describe('PostHogTelemetryProvider', () => {
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards additional metadata to legacy page view event', async () => {
|
||||
it('forwards additional metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -664,20 +708,6 @@ 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', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CancellationFlowClosedMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -126,7 +127,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: 'history_change',
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
@@ -536,4 +537,24 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
...properties
|
||||
})
|
||||
}
|
||||
|
||||
trackCancellationFlowOpened(): void {
|
||||
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_OPENED)
|
||||
}
|
||||
|
||||
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_CLOSED, metadata)
|
||||
|
||||
if (metadata.outcome !== 'reconsidered') return
|
||||
if (!this.posthog || !this.isEnabled) return
|
||||
if (this.disabledEvents.has(TelemetryEvents.CANCELLATION_FLOW_CLOSED))
|
||||
return
|
||||
try {
|
||||
this.posthog.people.set({
|
||||
cancellation_reconsidered_at: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog user property:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +452,18 @@ interface EcommerceMetadata {
|
||||
items: EcommerceItemMetadata[]
|
||||
}
|
||||
|
||||
export interface CancellationFlowClosedMetadata {
|
||||
outcome: 'canceled' | 'reconsidered' | 'discounted' | 'paused' | 'unknown'
|
||||
survey_response?: string
|
||||
/**
|
||||
* Categorized reason when `outcome === 'unknown'` so PostHog dashboards
|
||||
* can separate a failed cancel API call from an embed failure. Fallbacks
|
||||
* to the legacy dialog (auth endpoint missing, embed script blocked)
|
||||
* happen before the flow opens and emit no events at all.
|
||||
*/
|
||||
failure_reason?: 'cancel_api_failed' | 'unexpected'
|
||||
}
|
||||
|
||||
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
user_id?: string
|
||||
checkout_attempt_id: string
|
||||
@@ -564,6 +576,10 @@ export interface TelemetryProvider {
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
|
||||
// Cancellation flow events
|
||||
trackCancellationFlowOpened?(): void
|
||||
trackCancellationFlowClosed?(metadata: CancellationFlowClosedMetadata): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -660,7 +676,11 @@ export const TelemetryEvents = {
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
PAGE_VIEW: 'app:page_view',
|
||||
|
||||
// Cancellation Flow
|
||||
CANCELLATION_FLOW_OPENED: 'app:cancellation_flow_opened',
|
||||
CANCELLATION_FLOW_CLOSED: 'app:cancellation_flow_closed'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
@@ -709,3 +729,4 @@ export type TelemetryEventProperties =
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
| CancellationFlowClosedMetadata
|
||||
|
||||
@@ -335,6 +335,46 @@ describe('workspaceApi', () => {
|
||||
})
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
it('getChurnkeyAuth() returns the credentials on success', async () => {
|
||||
const data = {
|
||||
customer_id: 'cus_123',
|
||||
auth_hash: 'hash_abc',
|
||||
mode: 'live'
|
||||
}
|
||||
mockAxiosInstance.get.mockResolvedValue({ data })
|
||||
|
||||
const result = await workspaceApi.getChurnkeyAuth()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
||||
'/api/billing/churnkey/auth',
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
it('getChurnkeyAuth() returns null on 404 so callers fall back', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 404, data: { error: { message: 'Not Found' } } },
|
||||
message: 'Request failed'
|
||||
})
|
||||
|
||||
await expect(workspaceApi.getChurnkeyAuth()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('getChurnkeyAuth() rethrows non-404 errors', async () => {
|
||||
mockAxiosInstance.get.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { message: 'Server Error' } },
|
||||
message: 'Request failed'
|
||||
})
|
||||
|
||||
await expect(workspaceApi.getChurnkeyAuth()).rejects.toMatchObject({
|
||||
name: 'WorkspaceApiError',
|
||||
status: 500
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import type { ChurnkeyMode } from '@/platform/cloud/churnkey/types'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
WorkspaceId,
|
||||
@@ -214,6 +215,13 @@ interface PaymentPortalResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ChurnkeyAuthResponse {
|
||||
customer_id: string
|
||||
subscription_id?: string
|
||||
auth_hash: string
|
||||
mode: ChurnkeyMode
|
||||
}
|
||||
|
||||
interface PreviewPlanInfo {
|
||||
slug: string
|
||||
tier: SubscriptionTier
|
||||
@@ -775,6 +783,37 @@ export const workspaceApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Churnkey auth credentials (customer ID + HMAC) for the active workspace.
|
||||
* GET /api/billing/churnkey/auth
|
||||
* Used by the cancellation flow to launch the Churnkey embedded modal.
|
||||
*
|
||||
* Returns `null` on any 404 — callers fall back to the legacy cancel
|
||||
* dialog. Verified against production (2026-06-12): an undeployed route
|
||||
* hits the router's catch-all, which returns a JSON 404 body of
|
||||
* `{"error":{"message":"Not Found","type":"not_found"}}` (application
|
||||
* errors use a `{"code": ...}` shape instead, e.g. UNAUTHORIZED). A
|
||||
* future application-level 404 such as "no Churnkey customer" also
|
||||
* correctly falls back to the legacy dialog.
|
||||
*
|
||||
* The HMAC must be signed server-side; never derive it on the client.
|
||||
*/
|
||||
async getChurnkeyAuth(): Promise<ChurnkeyAuthResponse | null> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
const url = api.apiURL('/billing/churnkey/auth')
|
||||
try {
|
||||
const response = await workspaceApiClient.get<ChurnkeyAuthResponse>(url, {
|
||||
headers
|
||||
})
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||||
return null
|
||||
}
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get billing operation status
|
||||
* GET /api/billing/ops/:id
|
||||
|
||||
@@ -76,7 +76,7 @@ const mockManageSubscription = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockResubscribe = vi.fn()
|
||||
const mockShowLeaveWorkspaceDialog = vi.fn()
|
||||
const mockShowCancelSubscriptionDialog = vi.fn()
|
||||
const mockLaunchCancellationFlow = vi.fn()
|
||||
const mockShowEditWorkspaceDialog = vi.fn()
|
||||
const mockShowDeleteWorkspaceDialog = vi.fn()
|
||||
|
||||
@@ -198,7 +198,7 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showCancelSubscriptionDialog: mockShowCancelSubscriptionDialog,
|
||||
launchCancellationFlow: mockLaunchCancellationFlow,
|
||||
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
|
||||
showEditWorkspaceDialog: mockShowEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog
|
||||
@@ -635,7 +635,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel plan' }))
|
||||
expect(mockShowCancelSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
expect(mockLaunchCancellationFlow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('enables Delete for the original owner once the plan is cancelled', () => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const SELF_STYLED_PANEL_CONTENT_CLASS =
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
|
||||
|
||||
/**
|
||||
* Reka chrome shared by headless workspace dialogs whose content draws its
|
||||
* own panel — neutralize the DialogContent box and shrink-wrap it around the
|
||||
* content.
|
||||
*/
|
||||
export const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
@@ -26,7 +26,7 @@ export function useWorkspaceMenuItems() {
|
||||
deleteDisabledTooltipKey
|
||||
} = useWorkspaceUI()
|
||||
const {
|
||||
showCancelSubscriptionDialog,
|
||||
launchCancellationFlow,
|
||||
showEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showLeaveWorkspaceDialog
|
||||
@@ -37,7 +37,7 @@ export function useWorkspaceMenuItems() {
|
||||
}
|
||||
|
||||
function cancelSubscription() {
|
||||
void showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
void launchCancellationFlow(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
|
||||
function deleteWorkspace() {
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isRerouteNode
|
||||
? 'h-(--node-height)'
|
||||
: loadVideoShrinkWrapBody
|
||||
? 'h-auto min-h-0 min-w-(--min-node-width)'
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
@@ -79,9 +77,7 @@
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
loadVideoShrinkWrapBody
|
||||
? 'flex flex-none flex-col border border-solid border-transparent bg-node-component-header-surface'
|
||||
: 'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
@@ -155,8 +151,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-1 bg-component-node-background pt-1',
|
||||
loadVideoShrinkWrapBody ? 'flex-none' : 'flex-1',
|
||||
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
|
||||
bodyRoundingClass
|
||||
)
|
||||
"
|
||||
@@ -187,7 +182,7 @@
|
||||
v-if="!isTransparentHeaderless"
|
||||
v-bind="badges"
|
||||
:pricing="undefined"
|
||||
:class="loadVideoShrinkWrapBody ? undefined : 'mt-auto'"
|
||||
class="mt-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -265,7 +260,6 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { nodeHasLoadVideoPreview } from '@/composables/video/useLoadVideoPreview'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
@@ -296,10 +290,7 @@ import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import {
|
||||
requestVueElementFreshMeasurement,
|
||||
useVueElementTracking
|
||||
} from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
@@ -482,11 +473,7 @@ function initSizeStyles() {
|
||||
const fullHeight = height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
|
||||
if (loadVideoShrinkWrapBody.value) {
|
||||
el.style.removeProperty(`--node-height${suffix}`)
|
||||
} else {
|
||||
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
|
||||
}
|
||||
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -507,11 +494,9 @@ function handleLayoutChange(change: LayoutChange) {
|
||||
if (!el) return
|
||||
|
||||
const newSize = size.value
|
||||
const fullHeight = newSize.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
el.style.setProperty('--node-width', `${newSize.width}px`)
|
||||
if (!loadVideoShrinkWrapBody.value) {
|
||||
const fullHeight = newSize.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
el.style.setProperty('--node-height', `${fullHeight}px`)
|
||||
}
|
||||
el.style.setProperty('--node-height', `${fullHeight}px`)
|
||||
}
|
||||
|
||||
let unsubscribeLayoutChange: (() => void) | null = null
|
||||
@@ -745,28 +730,6 @@ const lgraphNode = computed(() => {
|
||||
return getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
})
|
||||
|
||||
const loadVideoShrinkWrapBody = computed(() => {
|
||||
if (nodeData.type !== 'LoadVideo') return false
|
||||
void nodeOutputs.nodeOutputs
|
||||
return nodeHasLoadVideoPreview(lgraphNode.value)
|
||||
})
|
||||
|
||||
watch(loadVideoShrinkWrapBody, async (shrinkWrap, wasShrinkWrap) => {
|
||||
if (shrinkWrap === wasShrinkWrap) return
|
||||
|
||||
const el = nodeContainerRef.value
|
||||
if (!el) return
|
||||
|
||||
if (shrinkWrap) {
|
||||
el.style.removeProperty('--node-height')
|
||||
} else {
|
||||
initSizeStyles()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
requestVueElementFreshMeasurement(el)
|
||||
})
|
||||
|
||||
// TODO: Surface subgraph info more cleanly in VueNodeData instead of
|
||||
// reaching through lgraphNode for promoted preview resolution.
|
||||
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
||||
hasExpandingRows && 'min-h-0',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
@@ -16,7 +15,7 @@
|
||||
"
|
||||
:style="{
|
||||
'grid-template-rows': gridTemplateRows,
|
||||
flex: hasExpandingRows ? 1 : undefined
|
||||
flex: gridTemplateRows.includes('auto') ? 1 : undefined
|
||||
}"
|
||||
@pointerdown.capture="handleBringToFront"
|
||||
@pointerdown="handleWidgetPointerEvent"
|
||||
@@ -28,9 +27,6 @@
|
||||
v-if="widget.visible"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
:class="
|
||||
widget.type === 'videotrim' && loadVideoTrimFillsSpace && 'min-h-0'
|
||||
"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
@@ -72,9 +68,6 @@
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.type === 'videotrim' &&
|
||||
loadVideoTrimFillsSpace &&
|
||||
'h-full min-h-0',
|
||||
widget.hasError && 'font-bold text-node-stroke-error'
|
||||
)
|
||||
"
|
||||
@@ -135,14 +128,8 @@ onErrorCaptured((error) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const {
|
||||
canSelectInputs,
|
||||
gridTemplateRows,
|
||||
hasExpandingRows,
|
||||
loadVideoTrimFillsSpace,
|
||||
nodeType,
|
||||
processedWidgets
|
||||
} = useProcessedWidgets(() => nodeData)
|
||||
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
|
||||
useProcessedWidgets(() => nodeData)
|
||||
|
||||
// Tracks widget-row growth that the node-level RO can't see
|
||||
if (nodeData?.id != null) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { nodeHasLoadVideoPreview } from '@/composables/video/useLoadVideoPreview'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
@@ -405,8 +403,6 @@ export function useProcessedWidgets(
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const { isSelectInputsMode } = useAppMode()
|
||||
const { handleNodeRightClick } = useNodeEventHandlers()
|
||||
|
||||
@@ -452,54 +448,17 @@ export function useProcessedWidgets(
|
||||
processedWidgets.value.filter((w) => w.visible)
|
||||
)
|
||||
|
||||
const loadVideoHasPreview = computed(() => {
|
||||
const nodeData = nodeDataGetter()
|
||||
if (nodeData?.type !== 'LoadVideo') return false
|
||||
const node = app.canvas.graph?.getNodeById(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
void nodeOutputStore.nodeOutputs
|
||||
void nodeOutputStore.nodePreviewImages
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (graphId) {
|
||||
void widgetValueStore.getWidget(widgetId(graphId, nodeData.id, 'file'))
|
||||
?.value
|
||||
}
|
||||
|
||||
return nodeHasLoadVideoPreview(node)
|
||||
})
|
||||
|
||||
const loadVideoTrimFillsSpace = computed(
|
||||
() => nodeType.value === 'LoadVideo' && !loadVideoHasPreview.value
|
||||
)
|
||||
|
||||
function widgetGridRow(widget: ProcessedWidget) {
|
||||
if (
|
||||
widget.type === 'videotrim' &&
|
||||
nodeType.value === 'LoadVideo' &&
|
||||
!loadVideoHasPreview.value
|
||||
) {
|
||||
return 'minmax(0, 1fr)'
|
||||
}
|
||||
if (shouldExpand(widget.type) || widget.hasLayoutSize) return 'auto'
|
||||
return 'min-content'
|
||||
}
|
||||
|
||||
const gridTemplateRows = computed((): string =>
|
||||
visibleWidgets.value.map(widgetGridRow).join(' ')
|
||||
)
|
||||
|
||||
const hasExpandingRows = computed(() =>
|
||||
visibleWidgets.value.some(
|
||||
(widget) => widgetGridRow(widget) !== 'min-content'
|
||||
)
|
||||
visibleWidgets.value
|
||||
.map((w) =>
|
||||
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||
)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
return {
|
||||
canSelectInputs,
|
||||
gridTemplateRows,
|
||||
hasExpandingRows,
|
||||
loadVideoTrimFillsSpace,
|
||||
nodeType,
|
||||
processedWidgets,
|
||||
visibleWidgets
|
||||
|
||||
@@ -87,12 +87,6 @@ function markElementForFreshMeasurement(element: HTMLElement) {
|
||||
cachedNodeMeasurements.delete(element)
|
||||
}
|
||||
|
||||
export function requestVueElementFreshMeasurement(element: HTMLElement) {
|
||||
if (!element.isConnected) return
|
||||
markElementForFreshMeasurement(element)
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
|
||||
watch(visibility, (state) => {
|
||||
if (state !== 'visible' || deferredElements.size === 0) return
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
v-if="isDropdownUIWidget"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-id="nodeId"
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
:asset-kind="assetKind"
|
||||
:allow-upload="allowUpload"
|
||||
@@ -36,7 +35,6 @@ import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
type StringControlWidget = SimplifiedControlWidget<string | undefined>
|
||||
@@ -44,7 +42,6 @@ type StringControlWidget = SimplifiedControlWidget<string | undefined>
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeType?: string
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
@@ -79,36 +79,6 @@ vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
|
||||
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
|
||||
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
|
||||
const { getNodeImageUrlsMock, mockLoadVideoNode, getNodeByIdMock } = vi.hoisted(
|
||||
() => ({
|
||||
getNodeImageUrlsMock: vi.fn<(node: unknown) => string[] | undefined>(
|
||||
() => undefined
|
||||
),
|
||||
getNodeByIdMock: vi.fn(),
|
||||
mockLoadVideoNode: {
|
||||
isUploading: false,
|
||||
widgets: [{ name: 'file', value: 'ltx2-audio_to_video.mov' }]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph: {
|
||||
getNodeById: getNodeByIdMock
|
||||
}
|
||||
},
|
||||
getPreviewFormatParam: () => ''
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs: {},
|
||||
getNodeImageUrls: getNodeImageUrlsMock
|
||||
})
|
||||
}))
|
||||
|
||||
const { mockItemsRef, mockSelectedSetRef, mockFilterSelectedRef } = vi.hoisted(
|
||||
() => {
|
||||
@@ -174,12 +144,6 @@ describe('WidgetSelectDropdown', () => {
|
||||
mockFilterSelectedRef.value = 'all'
|
||||
mockUpdateSelectedItems.mockClear()
|
||||
mockHandleFilesUpdate.mockClear()
|
||||
getNodeImageUrlsMock.mockReturnValue(undefined)
|
||||
mockLoadVideoNode.isUploading = false
|
||||
mockLoadVideoNode.widgets = [
|
||||
{ name: 'file', value: 'ltx2-audio_to_video.mov' }
|
||||
]
|
||||
getNodeByIdMock.mockReturnValue(mockLoadVideoNode)
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
@@ -293,44 +257,4 @@ describe('WidgetSelectDropdown', () => {
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadVideo file dropdown', () => {
|
||||
function renderLoadVideoDropdown(modelValue = 'ltx2-audio_to_video.mov') {
|
||||
mockItemsRef.value = [{ id: 'input-0', name: 'ltx2-audio_to_video.mov' }]
|
||||
mockSelectedSetRef.value = new Set(['input-0'])
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: modelValue,
|
||||
name: 'file',
|
||||
type: 'combo',
|
||||
options: {
|
||||
values: ['ltx2-audio_to_video.mov']
|
||||
}
|
||||
})
|
||||
return renderComponent(widget, modelValue, {
|
||||
assetKind: 'video',
|
||||
nodeType: 'LoadVideo',
|
||||
nodeId: 'load-video-node',
|
||||
allowUpload: false
|
||||
})
|
||||
}
|
||||
|
||||
it('stays enabled when preview resolves from the file widget fallback', () => {
|
||||
renderLoadVideoDropdown()
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'ltx2-audio_to_video.mov'
|
||||
})
|
||||
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables while the node is uploading', () => {
|
||||
mockLoadVideoNode.isUploading = true
|
||||
renderLoadVideoDropdown()
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'ltx2-audio_to_video.mov'
|
||||
})
|
||||
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useLoadVideoPreview } from '@/composables/video/useLoadVideoPreview'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
|
||||
@@ -15,9 +14,7 @@ import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
|
||||
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import {
|
||||
@@ -27,7 +24,6 @@ import {
|
||||
|
||||
interface Props {
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeId?: NodeId
|
||||
nodeType?: string
|
||||
assetKind?: AssetKind
|
||||
allowUpload?: boolean
|
||||
@@ -112,9 +108,7 @@ const mediaPlaceholder = computed(() => {
|
||||
case 'image':
|
||||
return t('widgets.uploadSelect.placeholderImage')
|
||||
case 'video':
|
||||
return props.nodeType === 'LoadVideo'
|
||||
? t('widgets.uploadSelect.browseAssetLibrary')
|
||||
: t('widgets.uploadSelect.placeholderVideo')
|
||||
return t('widgets.uploadSelect.placeholderVideo')
|
||||
case 'audio':
|
||||
return t('widgets.uploadSelect.placeholderAudio')
|
||||
case 'mesh':
|
||||
@@ -130,7 +124,6 @@ const mediaPlaceholder = computed(() => {
|
||||
|
||||
const uploadable = computed(() => {
|
||||
if (props.isAssetMode) return false
|
||||
if (props.nodeType === 'LoadVideo') return false
|
||||
return props.allowUpload === true
|
||||
})
|
||||
|
||||
@@ -170,38 +163,6 @@ const handleApproachEnd = useDebounceFn(async () => {
|
||||
}, 300)
|
||||
|
||||
const isUploading = ref(false)
|
||||
|
||||
const node = computed(() => {
|
||||
if (!props.nodeId) return undefined
|
||||
return app.canvas.graph?.getNodeById(props.nodeId)
|
||||
})
|
||||
|
||||
const { videoUrl: loadVideoPreviewUrl } = useLoadVideoPreview(node)
|
||||
|
||||
const nodeIsUploading = computed(() => node.value?.isUploading ?? false)
|
||||
|
||||
const awaitingVideoPreview = computed(() => {
|
||||
if (props.nodeType !== 'LoadVideo' || props.assetKind !== 'video') {
|
||||
return false
|
||||
}
|
||||
if (!modelValue.value) return false
|
||||
if (!node.value) return false
|
||||
return !loadVideoPreviewUrl.value
|
||||
})
|
||||
|
||||
const isLoadVideoProcessing = computed(
|
||||
() =>
|
||||
props.nodeType === 'LoadVideo' &&
|
||||
props.assetKind === 'video' &&
|
||||
(nodeIsUploading.value || awaitingVideoPreview.value)
|
||||
)
|
||||
|
||||
const dropdownDisabled = computed(() => isLoadVideoProcessing.value)
|
||||
|
||||
const dropdownIsUploading = computed(
|
||||
() => isUploading.value || isLoadVideoProcessing.value
|
||||
)
|
||||
|
||||
async function updateFiles(files: File[]) {
|
||||
isUploading.value = true
|
||||
await handleFilesUpdate(files)
|
||||
@@ -222,14 +183,13 @@ async function updateFiles(files: File[]) {
|
||||
:placeholder="mediaPlaceholder"
|
||||
:multiple="false"
|
||||
:uploadable
|
||||
:disabled="dropdownDisabled"
|
||||
:accept="acceptTypes"
|
||||
:filter-options
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
:is-uploading="dropdownIsUploading"
|
||||
:is-uploading
|
||||
v-bind="combinedProps"
|
||||
:loading-more="outputMediaAssets.isLoadingMore.value"
|
||||
class="w-full"
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="cn(!videoUrl && 'flex h-full min-h-0 min-w-0 flex-1 flex-col')"
|
||||
:data-widget-name="widget.name"
|
||||
>
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="videoUrl"
|
||||
:uploading="isUploading"
|
||||
:on-drag-over="handleDragOver"
|
||||
:on-drag-drop="handleDragDrop"
|
||||
@browse="handleBrowse"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import LoadVideoTrimPanel from '@/components/video/LoadVideoTrimPanel.vue'
|
||||
import { useLoadVideoPreview } from '@/composables/video/useLoadVideoPreview'
|
||||
import type { VideoTrimValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const { nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget<VideoTrimValue>
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<VideoTrimValue>({
|
||||
default: () => ({
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 0
|
||||
})
|
||||
})
|
||||
|
||||
const playheadFrame = ref(0)
|
||||
|
||||
const node = computed(() => app.canvas.graph?.getNodeById(nodeId))
|
||||
|
||||
const { videoUrl } = useLoadVideoPreview(node)
|
||||
|
||||
const isUploading = computed(() => node.value?.isUploading ?? false)
|
||||
|
||||
const trimEnabled = computed({
|
||||
get: () => modelValue.value.trimEnabled,
|
||||
set: (trimEnabled) => {
|
||||
modelValue.value = { ...modelValue.value, trimEnabled }
|
||||
}
|
||||
})
|
||||
|
||||
const startFrame = computed({
|
||||
get: () => modelValue.value.startFrame,
|
||||
set: (startFrame) => {
|
||||
modelValue.value = { ...modelValue.value, startFrame }
|
||||
}
|
||||
})
|
||||
|
||||
const endFrame = computed({
|
||||
get: () => modelValue.value.endFrame,
|
||||
set: (endFrame) => {
|
||||
modelValue.value = { ...modelValue.value, endFrame }
|
||||
}
|
||||
})
|
||||
|
||||
function handleBrowse() {
|
||||
node.value?.widgets
|
||||
?.find((widget) => widget.name === 'upload')
|
||||
?.callback?.(undefined)
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
const currentNode = node.value
|
||||
if (!currentNode) return
|
||||
|
||||
const fileWidget = currentNode.widgets?.find(
|
||||
(widget) => widget.name === 'file'
|
||||
)
|
||||
if (!fileWidget) return
|
||||
|
||||
const oldValue = fileWidget.value
|
||||
fileWidget.value = ''
|
||||
fileWidget.callback?.('')
|
||||
currentNode.onWidgetChanged?.('file', '', oldValue, fileWidget)
|
||||
|
||||
const graphId = currentNode.graph?.rootGraph?.id
|
||||
if (graphId) {
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(graphId, currentNode.id, 'file'),
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
useNodeOutputStore().removeNodeOutputsForNode(currentNode)
|
||||
currentNode.imgs = undefined
|
||||
currentNode.videoContainer = undefined
|
||||
|
||||
modelValue.value = {
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 0
|
||||
}
|
||||
playheadFrame.value = 0
|
||||
currentNode.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
return node.value?.onDragOver?.(event) ?? false
|
||||
}
|
||||
|
||||
function handleDragDrop(event: DragEvent) {
|
||||
event.stopPropagation()
|
||||
return node.value?.onDragDrop?.(event) ?? false
|
||||
}
|
||||
|
||||
watch(videoUrl, (url, previousUrl) => {
|
||||
playheadFrame.value = 0
|
||||
if (url && url !== previousUrl) {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -61,7 +61,6 @@ defineExpose({ focus })
|
||||
>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
|
||||
@@ -10,12 +10,3 @@ export const WidgetInputBaseClass = cn([
|
||||
// Rounded
|
||||
'rounded-lg'
|
||||
])
|
||||
|
||||
export const WidgetInputActionButtonClass = cn(
|
||||
WidgetInputBaseClass,
|
||||
'flex h-8 cursor-pointer items-center justify-center',
|
||||
'not-disabled:hover:bg-component-node-widget-background-hovered',
|
||||
'disabled:cursor-not-allowed disabled:bg-component-node-widget-background-disabled',
|
||||
'disabled:text-muted-foreground disabled:opacity-50',
|
||||
'disabled:hover:bg-component-node-widget-background-disabled'
|
||||
)
|
||||
|
||||
@@ -437,11 +437,8 @@ describe('useComboWidget', () => {
|
||||
]
|
||||
)
|
||||
|
||||
const expectedDefault =
|
||||
scenario.nodeClass === 'LoadVideo' ? '' : scenario.assetHash
|
||||
|
||||
expect(getInputWidgetDefault(mockNode)).toBe(expectedDefault)
|
||||
expect(widget.value).toBe(expectedDefault)
|
||||
expect(getInputWidgetDefault(mockNode)).toBe(scenario.assetHash)
|
||||
expect(widget.value).toBe(scenario.assetHash)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -471,11 +468,8 @@ describe('useComboWidget', () => {
|
||||
]
|
||||
)
|
||||
|
||||
const expectedDefault =
|
||||
scenario.nodeClass === 'LoadVideo' ? '' : scenario.assetHash
|
||||
|
||||
expect(getInputWidgetDefault(mockNode)).toBe(expectedDefault)
|
||||
expect(widget.value).toBe(expectedDefault)
|
||||
expect(getInputWidgetDefault(mockNode)).toBe(scenario.assetHash)
|
||||
expect(widget.value).toBe(scenario.assetHash)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -746,30 +740,6 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should start LoadVideo with an empty file selection in OSS', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('LoadVideo')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'file',
|
||||
options: ['edu social.mp4', 'other-video.mp4']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'file',
|
||||
'',
|
||||
expect.any(Function),
|
||||
{
|
||||
values: ['edu social.mp4', 'other-video.mp4']
|
||||
}
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should trigger lazy load for cloud input nodes', () => {
|
||||
const scenario = cloudInputScenarios[0]
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
@@ -25,10 +25,7 @@ import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec, nodeType?: string) => {
|
||||
if (nodeType === 'LoadVideo' && inputSpec.name === 'file') {
|
||||
return ''
|
||||
}
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
@@ -153,8 +150,6 @@ function resolveCloudInputDefault(
|
||||
nodeType: string | undefined,
|
||||
specDefault: string | undefined
|
||||
): string | undefined {
|
||||
if (nodeType === 'LoadVideo') return undefined
|
||||
|
||||
const assets = getCloudInputAssets(nodeType)
|
||||
if (specDefault != null) {
|
||||
const matchingAsset =
|
||||
@@ -243,7 +238,7 @@ const addComboWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
): IBaseWidget => {
|
||||
const defaultValue = getDefaultValue(inputSpec, node.comfyClass)
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
|
||||
@@ -110,27 +110,4 @@ describe('useImageUploadWidget', () => {
|
||||
fileComboWidget
|
||||
)
|
||||
})
|
||||
|
||||
it('does not preload preview when the file widget starts empty', () => {
|
||||
const { node } = createUploadNode()
|
||||
node.widgets![0].value = ''
|
||||
const constructor = useImageUploadWidget()
|
||||
|
||||
constructor(
|
||||
node,
|
||||
'upload',
|
||||
[
|
||||
'IMAGEUPLOAD',
|
||||
{ imageInputName: 'image', image_upload: true }
|
||||
] as InputSpec,
|
||||
fromPartial({})
|
||||
)
|
||||
|
||||
const raf = vi.mocked(requestAnimationFrame)
|
||||
expect(raf).toHaveBeenCalledTimes(1)
|
||||
raf.mock.calls[0]?.[0]?.(0)
|
||||
|
||||
expect(mocks.setNodeOutputs).not.toHaveBeenCalled()
|
||||
expect(mocks.showPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,13 +113,9 @@ export const useImageUploadWidget = () => {
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function () {
|
||||
node.imgs = undefined
|
||||
const raw = fileComboWidget.value
|
||||
if (raw == null || raw === '' || raw === 'Loading...') {
|
||||
nodeOutputStore.setNodeOutputs(node, '', { isAnimated })
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
return
|
||||
}
|
||||
nodeOutputStore.setNodeOutputs(node, String(raw), { isAnimated })
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
@@ -127,10 +123,7 @@ export const useImageUploadWidget = () => {
|
||||
// The value isn't set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
const raw = fileComboWidget.value
|
||||
if (raw == null || raw === '' || raw === 'Loading...') return
|
||||
|
||||
nodeOutputStore.setNodeOutputs(node, String(raw), {
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { useVideoTrimWidget } from './useVideoTrimWidget'
|
||||
|
||||
type MockWidget = {
|
||||
type: string
|
||||
name: string
|
||||
value: string | number | boolean | object
|
||||
callback?: (value: unknown) => void
|
||||
options: Record<string, unknown>
|
||||
y: number
|
||||
linkedWidgets?: MockWidget[]
|
||||
}
|
||||
|
||||
function createMockNode() {
|
||||
const widgets: MockWidget[] = []
|
||||
|
||||
const node = {
|
||||
widgets,
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string | number | boolean | object,
|
||||
callback: (value: unknown) => void,
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
const widget: MockWidget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
callback,
|
||||
options,
|
||||
y: 0
|
||||
}
|
||||
widgets.push(widget)
|
||||
return widget as unknown as IBaseWidget
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return { node, widgets }
|
||||
}
|
||||
|
||||
describe('useVideoTrimWidget', () => {
|
||||
it('creates parent and hidden linked trim widgets', () => {
|
||||
const { node, widgets } = createMockNode()
|
||||
|
||||
const parent = useVideoTrimWidget(node)
|
||||
|
||||
expect(widgets).toHaveLength(4)
|
||||
expect(parent.name).toBe('trim')
|
||||
expect(parent.type).toBe('videotrim')
|
||||
expect(parent.linkedWidgets).toHaveLength(3)
|
||||
expect(
|
||||
widgets.find((widget) => widget.name === 'trim_enabled')?.options
|
||||
).toMatchObject({
|
||||
canvasOnly: true,
|
||||
serialize: true
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs sub-widgets when parent value changes', () => {
|
||||
const { node, widgets } = createMockNode()
|
||||
const parent = useVideoTrimWidget(node)
|
||||
|
||||
parent.value = {
|
||||
trimEnabled: true,
|
||||
startFrame: 12,
|
||||
endFrame: 99
|
||||
}
|
||||
parent.callback?.(parent.value)
|
||||
|
||||
expect(
|
||||
widgets.find((widget) => widget.name === 'trim_enabled')?.value
|
||||
).toBe(true)
|
||||
expect(widgets.find((widget) => widget.name === 'start_frame')?.value).toBe(
|
||||
12
|
||||
)
|
||||
expect(widgets.find((widget) => widget.name === 'end_frame')?.value).toBe(
|
||||
99
|
||||
)
|
||||
})
|
||||
|
||||
it('updates parent when a linked sub-widget changes', () => {
|
||||
const { node, widgets } = createMockNode()
|
||||
const parent = useVideoTrimWidget(node)
|
||||
const parentCallback = vi.fn()
|
||||
parent.callback = parentCallback
|
||||
|
||||
const startFrameWidget = widgets.find(
|
||||
(widget) => widget.name === 'start_frame'
|
||||
)
|
||||
startFrameWidget?.callback?.(45)
|
||||
|
||||
expect(parent.value.startFrame).toBe(45)
|
||||
expect(parentCallback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
INumericWidget,
|
||||
IVideoTrimWidget,
|
||||
VideoTrimValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
function isNumericWidget(widget: IBaseWidget): widget is INumericWidget {
|
||||
return widget.type === 'number'
|
||||
}
|
||||
|
||||
function syncSubWidgets(
|
||||
parent: IVideoTrimWidget,
|
||||
trimEnabledWidget: IBaseWidget,
|
||||
startFrameWidget: INumericWidget,
|
||||
endFrameWidget: INumericWidget
|
||||
) {
|
||||
trimEnabledWidget.value = parent.value.trimEnabled
|
||||
startFrameWidget.value = parent.value.startFrame
|
||||
endFrameWidget.value = parent.value.endFrame
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the LoadVideo trim widget surface and linked sub-widgets.
|
||||
*
|
||||
* Extension migration: LoadVideo nodes now expose `trim` (videotrim),
|
||||
* `trim_enabled`, `start_frame`, and `end_frame` widgets. Code that reads
|
||||
* `node.widgets` by index or name should tolerate the new entries and prefer
|
||||
* lookup by widget name over positional access.
|
||||
*/
|
||||
export function useVideoTrimWidget(node: LGraphNode) {
|
||||
const defaultValue: VideoTrimValue = {
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 0
|
||||
}
|
||||
|
||||
const rawParent = node.addWidget(
|
||||
'videotrim',
|
||||
'trim',
|
||||
{ ...defaultValue },
|
||||
() => {},
|
||||
{
|
||||
serialize: false,
|
||||
canvasOnly: false
|
||||
}
|
||||
)
|
||||
|
||||
if (rawParent.type !== 'videotrim') {
|
||||
throw new Error(`Unexpected widget type: ${rawParent.type}`)
|
||||
}
|
||||
|
||||
const parent = rawParent as IVideoTrimWidget & {
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
}
|
||||
|
||||
const trimEnabledWidget = node.addWidget(
|
||||
'toggle',
|
||||
'trim_enabled',
|
||||
defaultValue.trimEnabled,
|
||||
function (this: IBaseWidget, value: boolean) {
|
||||
parent.value = { ...parent.value, trimEnabled: value }
|
||||
parent.callback?.(parent.value)
|
||||
},
|
||||
{
|
||||
serialize: true,
|
||||
canvasOnly: true,
|
||||
hidden: true
|
||||
}
|
||||
)
|
||||
|
||||
const startFrameWidget = node.addWidget(
|
||||
'number',
|
||||
'start_frame',
|
||||
defaultValue.startFrame,
|
||||
function (this: INumericWidget, value: number) {
|
||||
this.value = Math.round(value)
|
||||
parent.value = { ...parent.value, startFrame: this.value }
|
||||
parent.callback?.(parent.value)
|
||||
},
|
||||
{
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
serialize: true,
|
||||
canvasOnly: true,
|
||||
hidden: true
|
||||
}
|
||||
)
|
||||
|
||||
const endFrameWidget = node.addWidget(
|
||||
'number',
|
||||
'end_frame',
|
||||
defaultValue.endFrame,
|
||||
function (this: INumericWidget, value: number) {
|
||||
this.value = Math.round(value)
|
||||
parent.value = { ...parent.value, endFrame: this.value }
|
||||
parent.callback?.(parent.value)
|
||||
},
|
||||
{
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
serialize: true,
|
||||
canvasOnly: true,
|
||||
hidden: true
|
||||
}
|
||||
)
|
||||
|
||||
if (!isNumericWidget(startFrameWidget) || !isNumericWidget(endFrameWidget)) {
|
||||
throw new Error('Unexpected numeric widget type for video trim')
|
||||
}
|
||||
|
||||
parent.callback = () => {
|
||||
syncSubWidgets(parent, trimEnabledWidget, startFrameWidget, endFrameWidget)
|
||||
}
|
||||
|
||||
parent.linkedWidgets = [trimEnabledWidget, startFrameWidget, endFrameWidget]
|
||||
|
||||
return parent
|
||||
}
|
||||
@@ -69,9 +69,6 @@ const WidgetPainter = defineAsyncComponent(
|
||||
const WidgetRange = defineAsyncComponent(
|
||||
() => import('@/components/range/WidgetRange.vue')
|
||||
)
|
||||
const WidgetVideoTrim = defineAsyncComponent(
|
||||
() => import('../components/WidgetVideoTrim.vue')
|
||||
)
|
||||
const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
() => import('@/components/boundingBoxes/WidgetBoundingBoxes.vue')
|
||||
)
|
||||
@@ -229,14 +226,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'videotrim',
|
||||
{
|
||||
component: WidgetVideoTrim,
|
||||
aliases: ['VIDEOTRIM'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'boundingboxes',
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.v
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -456,12 +457,6 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
@@ -612,16 +607,15 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
async function showCancelSubscriptionDialog(cancelAt?: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'cancel-subscription',
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
const { showCancelSubscriptionDialog: show } =
|
||||
await import('@/platform/cloud/subscription/showCancelSubscriptionDialog')
|
||||
return show(cancelAt)
|
||||
}
|
||||
|
||||
async function launchCancellationFlow(cancelAt?: string): Promise<void> {
|
||||
const { launchCancellationFlow: launch } =
|
||||
await import('@/platform/cloud/subscription/launchCancellationFlow')
|
||||
return launch(cancelAt)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,6 +728,7 @@ export const useDialogService = () => {
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog,
|
||||
launchCancellationFlow,
|
||||
showDowngradeToPersonalDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,9 +374,6 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
return map
|
||||
})
|
||||
const allNodeDefsByDisplayName = computed(() => {
|
||||
return Object.fromEntries(nodeDefs.value.map((d) => [d.display_name, d]))
|
||||
})
|
||||
|
||||
const visibleNodeDefs = computed(() => {
|
||||
return nodeDefs.value.filter((nodeDef) =>
|
||||
@@ -511,7 +508,6 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
nodeDefsByName,
|
||||
nodeDefsByDisplayName,
|
||||
allNodeDefsByName,
|
||||
allNodeDefsByDisplayName,
|
||||
showDeprecated,
|
||||
showExperimental,
|
||||
showDevOnly,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchHttpResourceByteSize } from './httpResourceByteSize'
|
||||
|
||||
describe('fetchHttpResourceByteSize', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns Content-Length from a plausible HEAD response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '5242880' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchHttpResourceByteSize('https://example.com/video.mp4')
|
||||
).resolves.toBe(5242880)
|
||||
})
|
||||
|
||||
it('ignores implausible HEAD Content-Length and falls back to Range', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '53' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 206,
|
||||
headers: { 'Content-Range': 'bytes 0-0/5242880' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchHttpResourceByteSize('https://example.com/video.mp4')
|
||||
).resolves.toBe(5242880)
|
||||
})
|
||||
|
||||
it('returns undefined when neither HEAD nor Range expose a total size', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
|
||||
await expect(
|
||||
fetchHttpResourceByteSize('https://example.com/video.mp4')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses Content-Length from a full-body Range fallback response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '5242880' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchHttpResourceByteSize('https://example.com/video.mp4')
|
||||
).resolves.toBe(5242880)
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
const CONTENT_RANGE_TOTAL = /\/(\d+)$/
|
||||
|
||||
const MIN_PLAUSIBLE_VIDEO_BYTES = 1024
|
||||
|
||||
function parseContentLength(header: string | null): number | undefined {
|
||||
if (!header) return undefined
|
||||
const bytes = Number.parseInt(header, 10)
|
||||
if (!Number.isFinite(bytes) || bytes < MIN_PLAUSIBLE_VIDEO_BYTES) {
|
||||
return undefined
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function parseContentRangeTotal(header: string | null): number | undefined {
|
||||
if (!header) return undefined
|
||||
const match = header.match(CONTENT_RANGE_TOTAL)
|
||||
if (!match) return undefined
|
||||
return parseContentLength(match[1])
|
||||
}
|
||||
|
||||
export async function fetchHttpResourceByteSize(
|
||||
url: string
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
const headResponse = await fetch(url, { method: 'HEAD' })
|
||||
if (headResponse.ok) {
|
||||
const fromHead = parseContentLength(
|
||||
headResponse.headers.get('Content-Length')
|
||||
)
|
||||
if (fromHead != null) return fromHead
|
||||
}
|
||||
} catch {
|
||||
// Range fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const rangeResponse = await fetch(url, {
|
||||
headers: { Range: 'bytes=0-0' }
|
||||
})
|
||||
if (rangeResponse.status === 206) {
|
||||
return parseContentRangeTotal(rangeResponse.headers.get('Content-Range'))
|
||||
}
|
||||
if (rangeResponse.ok) {
|
||||
return parseContentLength(rangeResponse.headers.get('Content-Length'))
|
||||
}
|
||||
return undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user