Compare commits

..

17 Commits

Author SHA1 Message Date
uytieu
f89ab3ae55 fix: align NodeId types after merge 2026-06-30 05:02:10 -04:00
uytieu
29da036063 Merge branch 'main' into load-video-trim-node 2026-06-29 21:21:19 -04:00
steven-comfy
55c52a730a Enable cloud PostHog pageviews (#13286)
## Summary

This PR enables native PostHog `$pageview` capture for `cloud.comfy.org`
by setting cloud PostHog `capture_pageview` to `history_change`.

This keeps `autocapture` disabled, preserves the existing custom
`app:page_view` event, and lets the PostHog SDK capture the initial
pageview plus SPA history navigation pageviews. The goal is to make
cross-domain funnel tracking cleaner between `comfy.org` and
`cloud.comfy.org`, since `comfy.org` already emits native `$pageview`
events.

## Why

We want to measure the visitor funnel more accurately across:

- `comfy.org` visits
- `cloud.comfy.org` visits
- signup clicks / signup opened
- signup completion
- first cloud workflow run
- first subscription
- first credit purchase

Using native `$pageview` on both website and cloud should make PostHog
and downstream warehouse/Hex analysis cleaner for trackable users, while
leaving custom app pageview telemetry intact for existing consumers.

## Validation

- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
- `pnpm typecheck`
- `pnpm lint:unstaged`
- pre-commit hook: `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`
- pre-push hook: `knip --cache`

Note: local validation printed an engine warning because the Codex
runtime has Node `v24.14.0` while this repo declares `>=25 <26`; the
commands above still passed.
2026-06-30 00:27:13 +00:00
Denis
fbe462143a fix: re-export GroupNodeHandler for custom node compat (#13299)
Fixes #13175

#12931 slimmed groupNode.ts down to migration-only and dropped the
export on GroupNodeHandler.

ComfyUI-Manager still imports it (import { GroupNodeConfig,
GroupNodeHandler } from "../../extensions/core/groupNode.js" in
components-manager.js), so the legacy shim no longer providing that
export throws "does not provide an export named 'GroupNodeHandler'" at
module load. That kills the whole Manager extension before setup() runs
— which is why the Manager button vanished from the toolbar since 1.47.3
(backend loads fine, frontend JS dies).

Just re-adds the export (class is still there, only the keyword was
lost) plus the existing @knipIgnoreUnusedButUsedByCustomNodes tag since
nothing in src imports it.

Tested by loading with ComfyUI-Manager installed: the groupNode.js
import error is gone and the Manager button shows again.
typecheck/knip/lint pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:04:59 +00:00
uytieu
4cb82edc21 Merge branch 'load-video-trim-node' of https://github.com/Comfy-Org/ComfyUI_frontend into load-video-trim-node 2026-06-29 19:53:10 -04:00
uytieu
ca11b77d85 resolve code rabbit errors 2026-06-29 19:48:19 -04:00
uytieu
d50d219fb1 Merge branch 'main' into load-video-trim-node 2026-06-29 19:08:04 -04:00
nav-tej
61cb1bcde0 fix(website): point launches Comfy MCP CTA to /mcp (#13287)
*PR Created by the Glary-Bot Agent*

---

## Summary

Update the `EXPLORE` CTA on the Comfy MCP card on
[/launches](https://comfy.org/launches) to link to
[/mcp](https://comfy.org/mcp) instead of the docs
(`docs.comfy.org/agent-tools/cloud`).

## Change

Single line in `apps/website/src/data/drops.ts`:

```diff
     cta: {
       label: EXPLORE,
-      href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
+      href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
     }
```

Matches the locale-aware pattern used by sibling cards (`/download` /
`/zh-CN/download`, `/api` / `/zh-CN/api`). Both `src/pages/mcp.astro`
and `src/pages/zh-CN/mcp.astro` already exist, so neither link is dead.
The `externalLinks.docsMcp` constant is retained because the MCP page
itself still uses it.

## Verification

- `pnpm typecheck` / `pnpm typecheck:website` clean.
- `oxfmt`, `oxlint`, `eslint` clean (all ran via lint-staged on commit).
- Manually loaded `/launches` and `/zh-CN/launches` in the dev server
and confirmed the Comfy MCP card now points to `/mcp` and `/zh-CN/mcp`
respectively.
- Loaded `/mcp` and confirmed the destination page renders ("Comfy MCP —
Drive ComfyUI from any AI agent").
- Code review by Oracle: no issues.

Screenshot shows the updated MCP card on /launches.

## Screenshots

![Comfy MCP card on /launches with EXPLORE button now linking to
/mcp](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9f315ccc2692129159ae31efab9464684ff2f6db3e144feae6dd52fd314c0b47/pr-images/1782765166237-5e83667b-8dc3-4182-9891-609385a1dae5.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-29 22:29:38 +00:00
AustinMroz
9dcab4ee96 Essentials Cleanup (#13183)
Address several followup comments from #12744
2026-06-29 22:15:54 +00:00
Benjamin Lu
dc29f30b02 Track theme setting changes via telemetry (#13142)
Fix Color Palette changes not getting tracked, requested by design team.

Capture theme changes as `app:setting_changed` telemetry. The only
existing hook lived in `SettingItem.vue`, which renders *visible*
settings; `Comfy.ColorPalette` is hidden and changed through bespoke
theme UI, so it was never tracked.

Open to opinions here, we can also remove the hook in SettingItem.vue,
and just make everything that was visible opt in.

Linear:
https://linear.app/comfyorg/issue/GTM-158/track-theme-usage-with-posthog-events
2026-06-29 22:05:05 +00:00
uytieu
a2fd9cc1ed added disabled state for set and and frame buttons 2026-06-29 16:33:25 -04:00
uytieu
41ae77681d tooltip fix 2026-06-29 16:21:22 -04:00
uytieu
648e2f2383 Probe MP4 frame rate and resource byte size
Add utilities to detect MP4 average frame-rate and HTTP resource byte size and wire them into the video filmstrip loading flow. Files added: probeVideoFrameRate.ts (+test) and httpResourceByteSize.ts (+test). useVideoFilmstrip now probes frame-rate and fetches file size, exposes fps and fileSize refs, and uses those values when computing totalFrames. Removed redundant fileSize fetching from useLoadVideoPreview and updated components/stories/tests to consume the filmstrip-provided fileSize. Also fix fps usage to read ref.value and add a small layout spacing tweak (mt-2). Tests added/updated for the new behavior.
2026-06-27 00:39:05 -04:00
uytieu
bffa754e70 set start and end frame button interaction and trim frame logic 2026-06-27 00:22:02 -04:00
uytieu
9a1e1d0785 chore: remove unused i18n key and exported type 2026-06-27 00:04:53 -04:00
uytieu
2295d78bdd Removed loadVideoTrim.noVideo keys that were unused 2026-06-27 00:03:26 -04:00
uytieu
24b25b338f update 2026-06-26 23:58:42 -04:00
71 changed files with 4550 additions and 134 deletions

View File

@@ -95,7 +95,6 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,15 +67,7 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,13 +32,12 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,7 +21,6 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

5
.gitignore vendored
View File

@@ -96,4 +96,7 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.vercel
.env*
!.env_example

View File

@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
},
cta: {
label: EXPLORE,
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{

View File

@@ -1 +0,0 @@
PR 13291 origin CI guard smoke test.

View File

@@ -266,6 +266,9 @@
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
--video-trim-selection-background: var(--color-datatype-CLIP, #ffd500);
--video-trim-playhead-background: #f0513b;
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
--palette-interface-panel-surface: var(--comfy-menu-bg);
@@ -549,6 +552,10 @@
);
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
--color-video-trim-selection-background: var(
--video-trim-selection-background
);
--color-video-trim-playhead-background: var(--video-trim-playhead-background);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);

View File

@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />

View File

@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
}
</script>
<template>
<DropdownMenu button-class="icon-[lucide--list-filter]">
<DropdownMenu>
<template #button>
<Button size="icon" :aria-label="$t('g.filter')">
<i class="icon-[lucide--list-filter]" />
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
>
<span
class="flex-1"
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
/>
<DropdownMenuItemIndicator class="size-4 shrink-0">
<i class="icon-[lucide--check]" />

View File

@@ -117,8 +117,8 @@
</template>
<script setup lang="ts">
import { mapValues } from 'es-toolkit'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { mapValues } from 'es-toolkit'
import type { MenuItem } from 'primevue/menuitem'
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
import {

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({
inheritAttrs: false
})
const {
sideOffset = 4,
class: className,
arrowClass,
...restProps
} = defineProps<
TooltipContentProps & {
class?: HTMLAttributes['class']
arrowClass?: HTMLAttributes['class']
}
>()
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = computed(() => ({
sideOffset,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-fit rounded-md border bg-base-background px-3 py-1.5 text-sm text-base-foreground shadow-md',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)
"
>
<slot />
<TooltipArrow
:class="cn('fill-base-background', arrowClass)"
:width="10"
:height="5"
/>
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TooltipHint from './TooltipHint.vue'
import Button from '@/components/ui/button/Button.vue'
const meta: Meta<typeof TooltipHint> = {
title: 'Components/Tooltip/TooltipHint',
component: TooltipHint,
tags: ['autodocs'],
args: {
content: 'Tooltip hint',
side: 'top',
delayDuration: 300,
disabled: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { TooltipHint, Button },
setup() {
return { args }
},
template: `
<div class="flex items-center justify-center p-16">
<TooltipHint v-bind="args">
<Button variant="secondary">Hover me</Button>
</TooltipHint>
</div>
`
})
}
export const Disabled: Story = {
args: {
disabled: true,
content: 'Hidden tooltip'
},
render: Default.render
}
export const IconButton: Story = {
args: {
content: 'Set start frame'
},
render: (args) => ({
components: { TooltipHint },
setup() {
return { args }
},
template: `
<div class="flex items-center justify-center p-16">
<TooltipHint v-bind="args">
<button
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-component-node-widget-background text-component-node-foreground"
aria-label="Set start frame"
>
<i class="icon-[lucide--skip-back] size-4" />
</button>
</TooltipHint>
</div>
`
})
}

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'reka-ui'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipProvider from '@/components/ui/tooltip/TooltipProvider.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
content,
side = 'top',
sideOffset = 4,
delayDuration = 300,
disabled = false
} = defineProps<{
content: string
side?: TooltipContentProps['side']
sideOffset?: number
delayDuration?: number
disabled?: boolean
}>()
</script>
<template>
<TooltipProvider :delay-duration="delayDuration">
<Tooltip :disabled="disabled">
<TooltipTrigger as-child>
<slot />
</TooltipTrigger>
<TooltipContent
:side
:side-offset="sideOffset"
:class="
cn(
'rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2.5 py-1 text-xs leading-none text-node-component-tooltip shadow-none'
)
"
arrow-class="fill-node-component-tooltip-surface"
>
{{ content }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const { ...restProps } = defineProps<TooltipProviderProps>()
</script>
<template>
<TooltipProvider v-bind="restProps">
<slot />
</TooltipProvider>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const { ...restProps } = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger v-bind="restProps">
<slot />
</TooltipTrigger>
</template>

View File

@@ -0,0 +1,169 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref, toRefs } from 'vue'
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
const SAMPLE_VIDEO =
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
type StoryArgs = ComponentPropsAndSlots<typeof LoadVideoTrimPanel> & {
trimEnabled?: boolean
startFrame?: number
endFrame?: number
}
const meta: Meta<StoryArgs> = {
title: 'Components/Video/LoadVideoTrimPanel',
component: LoadVideoTrimPanel,
tags: ['autodocs'],
decorators: [
() => ({
template:
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
})
],
args: {
videoUrl: SAMPLE_VIDEO,
trimEnabled: false,
startFrame: 0,
endFrame: 400
}
}
export default meta
type Story = StoryObj<typeof meta>
function renderPanel(initialTrimEnabled: boolean) {
return (args: StoryArgs) => ({
components: { LoadVideoTrimPanel },
setup() {
const { videoUrl } = toRefs(args)
const trimEnabled = ref(initialTrimEnabled)
const startFrame = ref(args.startFrame ?? 0)
const endFrame = ref(args.endFrame ?? 400)
const playheadFrame = ref(0)
return {
videoUrl,
trimEnabled,
startFrame,
endFrame,
playheadFrame
}
},
template: `
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="videoUrl"
/>
`
})
}
export const TrimDisabled: Story = {
render: renderPanel(false)
}
export const TrimEnabled: Story = {
render: renderPanel(true)
}
export const EmptyNoVideo: Story = {
args: {
videoUrl: undefined
},
render: (args) => ({
components: { LoadVideoTrimPanel },
setup() {
const trimEnabled = ref(false)
const startFrame = ref(0)
const endFrame = ref(0)
const playheadFrame = ref(0)
const uploading = ref(false)
function handleBrowse() {
uploading.value = true
setTimeout(() => {
uploading.value = false
}, 1200)
}
return {
args,
trimEnabled,
startFrame,
endFrame,
playheadFrame,
uploading,
handleBrowse
}
},
template: `
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="args.videoUrl"
:uploading="uploading"
@browse="handleBrowse"
/>
`
})
}
export const EmptyNodeLayout: Story = {
args: {
videoUrl: undefined
},
render: (args) => ({
components: { LoadVideoTrimPanel },
setup() {
const trimEnabled = ref(false)
const startFrame = ref(0)
const endFrame = ref(0)
const playheadFrame = ref(0)
const uploading = ref(false)
return {
args,
trimEnabled,
startFrame,
endFrame,
playheadFrame,
uploading
}
},
template: `
<div class="flex flex-col gap-2">
<div class="px-2">
<label class="mb-1 block text-sm text-muted-foreground">video</label>
<div class="flex h-8 items-center justify-between rounded-lg bg-component-node-widget-background px-2 text-sm text-text-secondary">
<span>Browse asset library</span>
<i class="icon-[lucide--chevron-down] size-4 text-component-node-foreground-secondary" />
</div>
</div>
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="args.videoUrl"
:uploading="uploading"
/>
</div>
`
})
}
export const LongVideoManyFrames: Story = {
args: {
videoUrl: SAMPLE_VIDEO,
startFrame: 120,
endFrame: 3600
},
render: renderPanel(true)
}

View File

@@ -0,0 +1,446 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/vue'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
vi.mock('@/composables/video/useVideoFilmstrip', () => ({
DEFAULT_VIDEO_FPS: 30,
useVideoFilmstrip: () => ({
thumbnails: ref<string[]>(['data:image/jpeg;base64,one']),
duration: ref(10),
totalFrames: ref(101),
width: ref(1920),
height: ref(1080),
fps: ref(30),
fileSize: ref(5 * 1024 * 1024),
loading: ref(false)
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
increment: 'Increment',
decrement: 'Decrement',
remove: 'Remove'
},
loadVideoTrim: {
trimVideo: 'Trim Video',
startFrame: 'Start Frame',
endFrame: 'End Frame',
setStartFrame: 'Set start frame',
setEndFrame: 'Set end frame',
play: 'Play',
pause: 'Pause',
adjustStartFrame: 'Adjust start frame',
adjustEndFrame: 'Adjust end frame',
duration: 'Duration',
frames: 'Number of Frames',
fileSize: 'File Size',
durationZero: '0s',
durationSeconds: '{count}s',
fileSizeUnknown: '—',
fileSizeBytes: '{count} B',
fileSizeKilobytes: '{count} KB',
fileSizeMegabytes: '{count} MB',
resolution: '{width} × {height}',
dragAndDropVideos: 'Drag and drop videos here to upload',
uploadFromDevice: 'Upload from device',
uploading: 'Uploading…',
loadingVideo: 'Loading video preview'
}
}
}
})
type PanelProps = ComponentProps<typeof LoadVideoTrimPanel>
async function flushPromises() {
await Promise.resolve()
await Promise.resolve()
}
function renderPanel(props: PanelProps) {
return render(LoadVideoTrimPanel, {
props,
global: {
plugins: [i18n]
}
})
}
describe('LoadVideoTrimPanel', () => {
it('shows upload empty state and hides trim controls when no video', () => {
renderPanel({
videoUrl: undefined
})
expect(screen.getByTestId('media-upload-empty')).toBeTruthy()
expect(screen.queryByText('Trim Video')).toBeNull()
})
it('shows trim controls when video is loaded', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
expect(screen.queryByTestId('media-upload-empty')).toBeNull()
expect(screen.getByText('Trim Video')).toBeTruthy()
})
it('keeps the filmstrip visible when trim is toggled off', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: false
})
expect(screen.getByTestId('trim-track')).toBeTruthy()
expect(screen.queryByText('Start Frame')).toBeNull()
expect(screen.queryByText('End Frame')).toBeNull()
})
it('shows drag and drop empty state while not uploading', () => {
renderPanel({
videoUrl: undefined,
uploading: false
})
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
expect(screen.queryByText('Uploading…')).toBeNull()
})
it('shows uploading state only while an upload is in progress', () => {
renderPanel({
videoUrl: undefined,
uploading: true
})
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
expect(screen.getByText('Uploading…')).toBeTruthy()
})
it('shows remove button and emits remove when clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
const removeButton = screen.getByTestId('video-remove-button')
expect(removeButton).toBeTruthy()
expect(removeButton).toHaveAttribute('aria-label', 'Remove')
await user.click(removeButton)
expect(emitted().remove).toHaveLength(1)
})
it('activates remove from keyboard', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
const removeButton = screen.getByTestId('video-remove-button')
removeButton.focus()
await user.keyboard('{Enter}')
expect(emitted().remove).toHaveLength(1)
})
it('forwards browse event from empty state', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: undefined
})
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toHaveLength(1)
})
it('keeps playhead when trim edges move without collision', async () => {
const playheadFrame = ref(50)
const startFrame = ref(10)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
startFrame.value = 20
await Promise.resolve()
expect(playheadFrame.value).toBe(50)
})
it('moves playhead when trim edge collides with it', async () => {
const playheadFrame = ref(50)
const startFrame = ref(10)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
startFrame.value = 60
await Promise.resolve()
expect(playheadFrame.value).toBe(60)
})
it('moves playhead when start frame increment passes playhead', async () => {
const user = userEvent.setup()
const playheadFrame = ref(50)
const startFrame = ref(50)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getAllByTestId('increment')[0])
expect(startFrame.value).toBe(51)
expect(playheadFrame.value).toBe(51)
})
it('disables set start and end frame when trim handles are at defaults', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: true,
startFrame: 0,
endFrame: 100,
playheadFrame: 0
})
expect(screen.getByLabelText('Set start frame')).toBeDisabled()
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
})
it('disables set end frame when trim end is already at the last frame', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: true,
startFrame: 10,
endFrame: 100,
playheadFrame: 50
})
expect(screen.getByLabelText('Set start frame')).not.toBeDisabled()
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
})
it('resets the start trim handle to the first frame', async () => {
const user = userEvent.setup()
const startFrame = ref(10)
const endFrame = ref(100)
const playheadFrame = ref(50)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getByLabelText('Set start frame'))
expect(startFrame.value).toBe(0)
expect(playheadFrame.value).toBe(0)
})
it('resets the end trim handle to the last frame', async () => {
const user = userEvent.setup()
const startFrame = ref(10)
const endFrame = ref(80)
const playheadFrame = ref(50)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getByLabelText('Set end frame'))
expect(endFrame.value).toBe(100)
expect(playheadFrame.value).toBe(100)
})
it('seeks the video preview when scrubbing the filmstrip', async () => {
const playheadFrame = ref(0)
const startFrame = ref(0)
const endFrame = ref(100)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
const video = screen.getByTestId('video-preview') as HTMLVideoElement
let currentTime = 0
Object.defineProperty(video, 'currentTime', {
get: () => currentTime,
set: (value: number) => {
currentTime = value
},
configurable: true
})
Object.defineProperty(video, 'duration', {
value: 10,
configurable: true
})
await fireEvent.loadedMetadata(video)
await flushPromises()
await fireEvent.seeked(video)
await flushPromises()
const track = screen.getByTestId('trim-track')
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 200,
height: 64,
right: 200,
bottom: 64,
x: 0,
y: 0,
toJSON: () => ({})
})
track.setPointerCapture = vi.fn()
// eslint-disable-next-line testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events
await fireEvent.pointerDown(track, {
clientX: 100,
button: 0,
pointerId: 1
})
await flushPromises()
await fireEvent.seeked(video)
await flushPromises()
expect(playheadFrame.value).toBe(50)
expect(currentTime).toBe(5)
})
})

View File

@@ -0,0 +1,501 @@
<template>
<div
class="flex flex-col gap-2"
:class="!videoUrl && 'min-h-0 flex-1 pb-3'"
@pointerdown.stop
>
<MediaUploadEmpty
v-if="!videoUrl"
fill
accept="video/*"
:disabled="uploadDisabled"
:uploading
:on-drag-over
:on-drag-drop
@browse="emit('browse')"
/>
<div
v-else
data-testid="video-preview-container"
class="group relative w-full"
:style="videoAspectRatioStyle"
>
<div
class="relative size-full overflow-hidden rounded-lg bg-node-component-surface"
>
<video
ref="videoRef"
data-testid="video-preview"
:src="videoUrl"
class="size-full object-contain"
preload="auto"
muted
playsinline
@loadedmetadata="handleVideoMetadata"
@timeupdate="handleTimeUpdate"
/>
<div
v-if="filmstripLoading"
class="absolute inset-0 flex flex-col items-center justify-center gap-0 bg-node-component-surface"
data-testid="video-preview-loading"
:aria-busy="true"
:aria-label="t('loadVideoTrim.loadingVideo')"
>
<Loader size="md" variant="loader-circle" />
<p class="text-sm text-muted-foreground">
{{ t('loadVideoTrim.loadingVideo') }}
</p>
</div>
</div>
<TooltipHint v-if="!filmstripLoading" :content="t('g.remove')">
<button
type="button"
data-testid="video-remove-button"
:class="
cn(
removeButtonClass,
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
)
"
:aria-label="t('g.remove')"
@pointerdown.stop
@click.stop="emit('remove')"
>
<i class="icon-[lucide--x] size-4" />
</button>
</TooltipHint>
</div>
<div
v-if="videoUrl"
class="grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-1"
>
<WidgetToggleSwitch
v-model="trimEnabled"
class="col-span-full grid grid-cols-subgrid"
:widget="trimToggleWidget"
/>
<VideoFilmstripTrim
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
v-model:is-playing="isPlaying"
class="col-span-full mt-2"
:trim-enabled="trimEnabled"
:total-frames="effectiveTotalFrames"
:thumbnails="thumbnails"
@scrub="handleScrub"
/>
<WidgetInputNumberInput
v-if="trimEnabled"
v-model="startFrame"
root-class="col-span-full grid grid-cols-subgrid items-center"
:widget="startFrameWidget"
/>
<WidgetInputNumberInput
v-if="trimEnabled"
v-model="endFrame"
root-class="col-span-full grid grid-cols-subgrid items-center"
:widget="endFrameWidget"
/>
<div v-if="trimEnabled" class="col-span-full grid grid-cols-2 gap-1">
<TooltipHint
:content="t('loadVideoTrim.setStartFrame')"
:disabled="setStartFrameDisabled"
>
<button
type="button"
:class="WidgetInputActionButtonClass"
:disabled="setStartFrameDisabled"
:aria-label="t('loadVideoTrim.setStartFrame')"
@click="setStartFrame"
>
<i class="icon-[lucide--skip-back] size-4" />
</button>
</TooltipHint>
<TooltipHint
:content="t('loadVideoTrim.setEndFrame')"
:disabled="setEndFrameDisabled"
>
<button
type="button"
:class="WidgetInputActionButtonClass"
:disabled="setEndFrameDisabled"
:aria-label="t('loadVideoTrim.setEndFrame')"
@click="setEndFrame"
>
<i class="icon-[lucide--skip-forward] size-4" />
</button>
</TooltipHint>
</div>
<div
class="col-span-full mt-2 grid grid-cols-subgrid gap-y-0.5 border-t border-node-stroke py-2"
>
<div
v-for="row in metadataRows"
:key="row.label"
class="col-span-full grid grid-cols-subgrid py-0.5 text-sm"
>
<span class="truncate text-muted-foreground">{{ row.label }}</span>
<span class="text-right text-base-foreground">{{ row.value }}</span>
</div>
</div>
<p
v-if="resolutionLabel"
class="col-span-full m-0 border-t border-node-stroke py-3 text-center text-sm text-base-foreground"
>
{{ resolutionLabel }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { clamp } from 'es-toolkit'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import MediaUploadEmpty from '@/components/video/MediaUploadEmpty.vue'
import VideoFilmstripTrim from '@/components/video/VideoFilmstripTrim.vue'
import TooltipHint from '@/components/ui/tooltip/TooltipHint.vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
DEFAULT_VIDEO_FPS,
useVideoFilmstrip
} from '@/composables/video/useVideoFilmstrip'
import { WidgetInputActionButtonClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
videoUrl,
uploading = false,
uploadDisabled = false,
onDragOver,
onDragDrop
} = defineProps<{
videoUrl?: string
uploading?: boolean
uploadDisabled?: boolean
onDragOver?: (event: DragEvent) => boolean
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
}>()
const emit = defineEmits<{
browse: []
remove: []
}>()
const removeButtonClass =
'absolute top-2 right-2 z-10 flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-transparent'
const trimEnabled = defineModel<boolean>('trimEnabled', { default: false })
const startFrame = defineModel<number>('startFrame', { default: 0 })
const endFrame = defineModel<number>('endFrame', { default: 0 })
const playheadFrame = defineModel<number>('playheadFrame', { default: 0 })
const { t } = useI18n()
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const isPlaying = ref(false)
const isSeeking = ref(false)
const videoIntrinsicSize = ref<{ width: number; height: number } | null>(null)
let activeSeekId = 0
const videoUrlRef = computed(() => videoUrl)
const {
thumbnails,
duration,
totalFrames,
width,
height,
fps,
fileSize,
loading: filmstripLoading
} = useVideoFilmstrip(videoUrlRef)
const effectiveTotalFrames = computed(() => Math.max(totalFrames.value, 1))
const frameMax = computed(() => Math.max(totalFrames.value - 1, 0))
const controlsDisabled = computed(() => !trimEnabled.value || !videoUrl)
const setStartFrameDisabled = computed(
() => controlsDisabled.value || startFrame.value <= 0
)
const setEndFrameDisabled = computed(
() => controlsDisabled.value || endFrame.value >= frameMax.value
)
const trimToggleWidget = computed(
(): SimplifiedWidget<boolean> => ({
name: 'trim_enabled',
label: t('loadVideoTrim.trimVideo'),
type: 'toggle',
value: trimEnabled.value
})
)
const startFrameWidget = computed(
(): SimplifiedWidget<number> => ({
name: 'start_frame',
label: t('loadVideoTrim.startFrame'),
type: 'number',
value: startFrame.value,
options: {
min: 0,
max: Math.max(endFrame.value - 1, 0),
step: 1,
step2: 1,
precision: 0,
disabled: !videoUrl
}
})
)
const endFrameWidget = computed(
(): SimplifiedWidget<number> => ({
name: 'end_frame',
label: t('loadVideoTrim.endFrame'),
type: 'number',
value: endFrame.value,
options: {
min: Math.min(startFrame.value + 1, effectiveTotalFrames.value - 1),
max: Math.max(effectiveTotalFrames.value - 1, 0),
step: 1,
step2: 1,
precision: 0,
disabled: !videoUrl
}
})
)
const videoAspectRatioStyle = computed(() => {
const intrinsic = videoIntrinsicSize.value
const aspectWidth = width.value || intrinsic?.width
const aspectHeight = height.value || intrinsic?.height
if (aspectWidth && aspectHeight) {
return { aspectRatio: `${aspectWidth} / ${aspectHeight}` }
}
return { aspectRatio: '16 / 9' }
})
const metadataRows = computed(() => [
{
label: t('loadVideoTrim.duration'),
value: formatDuration(duration.value)
},
{
label: t('loadVideoTrim.frames'),
value: String(effectiveTotalFrames.value)
},
{
label: t('loadVideoTrim.fileSize'),
value: formatFileSize(fileSize.value)
}
])
const resolutionLabel = computed(() => {
const intrinsic = videoIntrinsicSize.value
const displayWidth = width.value || intrinsic?.width
const displayHeight = height.value || intrinsic?.height
if (!displayWidth || !displayHeight) return ''
return t('loadVideoTrim.resolution', {
width: displayWidth,
height: displayHeight
})
})
watch(
() => videoUrl,
() => {
startFrame.value = 0
playheadFrame.value = 0
endFrame.value = 0
isPlaying.value = false
videoIntrinsicSize.value = null
}
)
watch(
totalFrames,
(frames) => {
if (!videoUrl || frames <= 0) return
const lastFrame = Math.max(frames - 1, 0)
if (endFrame.value === 0 || endFrame.value > lastFrame) {
endFrame.value = lastFrame
}
playheadFrame.value = clamp(playheadFrame.value, 0, frameMax.value)
},
{ immediate: true }
)
watch([startFrame, endFrame], ([start, end]) => {
if (start >= end && end > 0) {
startFrame.value = Math.max(end - 1, 0)
}
resolvePlayheadTrimCollision()
})
watch(isPlaying, (playing) => {
void handlePlaybackChange(playing)
})
async function handlePlaybackChange(playing: boolean) {
const video = videoRef.value
if (!video) return
if (playing) {
const startAt = trimEnabled.value
? clamp(playheadFrame.value, startFrame.value, endFrame.value)
: clamp(playheadFrame.value, 0, frameMax.value)
await seekPreviewToFrame(startAt)
if (!isPlaying.value) return
try {
await video.play()
} catch {
isPlaying.value = false
}
} else {
video.pause()
}
}
function frameToTime(frame: number) {
if (duration.value > 0 && frameMax.value > 0) {
return (frame / frameMax.value) * duration.value
}
return frame / (fps.value || DEFAULT_VIDEO_FPS)
}
function clampSeekTime(video: HTMLVideoElement, time: number) {
if (!Number.isFinite(video.duration) || video.duration <= 0) {
return Math.max(time, 0)
}
return clamp(time, 0, Math.max(video.duration - 0.001, 0))
}
function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
return new Promise((resolve) => {
const finish = () => {
video.removeEventListener('seeked', finish)
video.removeEventListener('error', finish)
resolve()
}
video.addEventListener('seeked', finish, { once: true })
video.addEventListener('error', finish, { once: true })
})
}
async function seekPreviewToFrame(frame: number) {
const video = videoRef.value
if (!video) return
const clamped = clamp(frame, 0, frameMax.value)
playheadFrame.value = clamped
const targetTime = clampSeekTime(video, frameToTime(clamped))
if (Math.abs(video.currentTime - targetTime) <= 0.0001) return
const seekId = ++activeSeekId
isSeeking.value = true
video.currentTime = targetTime
await waitForVideoSeek(video)
if (seekId === activeSeekId) {
isSeeking.value = false
}
}
function resolvePlayheadTrimCollision() {
if (!trimEnabled.value) return
const start = startFrame.value
const end = endFrame.value
const previous = playheadFrame.value
if (previous < start) {
playheadFrame.value = start
} else if (previous > end) {
playheadFrame.value = end
}
if (!isPlaying.value && playheadFrame.value !== previous) {
void seekPreviewToFrame(playheadFrame.value)
}
}
function handleScrub(frame: number) {
isPlaying.value = false
void seekPreviewToFrame(frame)
}
function handleVideoMetadata() {
const video = videoRef.value
if (video?.videoWidth && video.videoHeight) {
videoIntrinsicSize.value = {
width: video.videoWidth,
height: video.videoHeight
}
}
void seekPreviewToFrame(playheadFrame.value)
}
function timeToFrame(time: number) {
if (duration.value > 0 && frameMax.value > 0) {
return Math.round((time / duration.value) * frameMax.value)
}
return Math.round(time * (fps.value || DEFAULT_VIDEO_FPS))
}
function handleTimeUpdate() {
const video = videoRef.value
if (!video || !isPlaying.value || isSeeking.value) return
const frame = timeToFrame(video.currentTime)
const minFrame = trimEnabled.value ? startFrame.value : 0
const maxFrame = trimEnabled.value ? endFrame.value : frameMax.value
playheadFrame.value = clamp(frame, minFrame, maxFrame)
if (frame >= maxFrame) {
isPlaying.value = false
void seekPreviewToFrame(maxFrame)
}
}
function setStartFrame() {
isPlaying.value = false
startFrame.value = 0
void seekPreviewToFrame(0)
}
function setEndFrame() {
isPlaying.value = false
endFrame.value = frameMax.value
void seekPreviewToFrame(frameMax.value)
}
function formatDuration(seconds: number) {
if (!seconds) return t('loadVideoTrim.durationZero')
return t('loadVideoTrim.durationSeconds', { count: Math.round(seconds) })
}
function formatFileSize(bytes?: number) {
if (bytes == null) return t('loadVideoTrim.fileSizeUnknown')
if (bytes < 1024) {
return t('loadVideoTrim.fileSizeBytes', { count: bytes })
}
if (bytes < 1024 * 1024) {
return t('loadVideoTrim.fileSizeKilobytes', {
count: Math.round(bytes / 1024)
})
}
return t('loadVideoTrim.fileSizeMegabytes', {
count: Number((bytes / (1024 * 1024)).toFixed(1))
})
}
</script>

View File

@@ -0,0 +1,80 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaUploadEmpty from './MediaUploadEmpty.vue'
type StoryArgs = ComponentPropsAndSlots<typeof MediaUploadEmpty>
const meta: Meta<StoryArgs> = {
title: 'Components/Video/MediaUploadEmpty',
component: MediaUploadEmpty,
tags: ['autodocs'],
decorators: [
() => ({
template:
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
})
],
args: {
accept: 'video/*',
disabled: false,
uploading: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { MediaUploadEmpty },
setup() {
const uploading = ref(false)
function handleBrowse() {
uploading.value = true
setTimeout(() => {
uploading.value = false
}, 1200)
}
return { args, uploading, handleBrowse }
},
template: `
<MediaUploadEmpty
v-bind="args"
:uploading="uploading"
@browse="handleBrowse"
/>
`
})
}
export const Uploading: Story = {
args: {
uploading: true
}
}
export const Disabled: Story = {
args: {
disabled: true
}
}
export const Hovered: Story = {
render: (args) => ({
components: { MediaUploadEmpty },
setup() {
return { args }
},
template: `
<MediaUploadEmpty
v-bind="args"
class="border-component-node-foreground-secondary bg-component-node-widget-background-hovered"
/>
`
})
}

View File

@@ -0,0 +1,188 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick, ref, watch } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import MediaUploadEmpty from './MediaUploadEmpty.vue'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
function useDropZone(
target: { value: HTMLElement | null | undefined },
options?:
| {
onDrop?: (files: File[] | null, event: DragEvent) => void
onOver?: (files: File[] | null, event: DragEvent) => void
onLeave?: (files: File[] | null, event: DragEvent) => void
}
| ((files: File[] | null, event: DragEvent) => void)
) {
const isOverDropZone = ref(false)
const resolved =
typeof options === 'function' ? { onDrop: options } : options
watch(
() => target.value,
(element, _, onCleanup) => {
if (!element || !resolved) return
const callbacks = resolved
function onDragOver(event: DragEvent) {
event.preventDefault()
isOverDropZone.value = true
callbacks.onOver?.(Array.from(event.dataTransfer?.files ?? []), event)
}
function onDrop(event: DragEvent) {
event.preventDefault()
isOverDropZone.value = false
callbacks.onDrop?.(Array.from(event.dataTransfer?.files ?? []), event)
}
function onDragLeave(event: DragEvent) {
isOverDropZone.value = false
callbacks.onLeave?.(null, event)
}
element.addEventListener('dragover', onDragOver)
element.addEventListener('drop', onDrop)
element.addEventListener('dragleave', onDragLeave)
onCleanup(() => {
element.removeEventListener('dragover', onDragOver)
element.removeEventListener('drop', onDrop)
element.removeEventListener('dragleave', onDragLeave)
})
},
{ immediate: true }
)
return { isOverDropZone }
}
return { ...actual, useDropZone }
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
loadVideoTrim: {
dragAndDropVideos: 'Drag and drop videos here to upload',
uploadFromDevice: 'Upload from device',
uploading: 'Uploading…'
},
g: {
loading: 'Loading'
}
}
}
})
function dragPayload(files: File[] = []) {
return {
dataTransfer: {
files,
types: ['Files'],
items: files.map((file) => ({
kind: 'file',
type: file.type,
getAsFile: () => file
}))
}
}
}
async function renderEmpty(
props: Partial<ComponentProps<typeof MediaUploadEmpty>> = {}
) {
const result = render(MediaUploadEmpty, {
props: {
accept: 'video/*',
...props
},
global: {
plugins: [i18n]
}
})
await nextTick()
return result
}
async function simulateDrop(
target: HTMLElement,
payload: ReturnType<typeof dragPayload>
) {
await fireEvent.dragOver(target, payload)
await fireEvent.drop(target, payload)
}
describe('MediaUploadEmpty', () => {
it('renders drag-drop prompt and upload button', async () => {
await renderEmpty()
expect(screen.getByText('Drag and drop videos here to upload')).toBeTruthy()
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
expect(screen.getByText('Upload from device')).toBeTruthy()
})
it('emits browse when upload button is clicked', async () => {
const user = userEvent.setup()
const { emitted } = await renderEmpty()
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toHaveLength(1)
})
it('emits upload with video files on drop', async () => {
const { emitted } = await renderEmpty()
const zone = screen.getByTestId('media-upload-empty')
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' })
await simulateDrop(zone, dragPayload([file]))
expect(emitted().upload).toHaveLength(1)
expect((emitted().upload[0] as [File[]])[0][0].name).toBe('clip.mp4')
})
it('delegates drag events to provided handlers', async () => {
const onDragOver = vi.fn(() => true)
const onDragDrop = vi.fn(() => true)
await renderEmpty({ onDragOver, onDragDrop })
const zone = screen.getByTestId('media-upload-empty')
await simulateDrop(zone, dragPayload([]))
expect(onDragOver).toHaveBeenCalled()
expect(onDragDrop).toHaveBeenCalled()
})
it('does not emit browse when disabled', async () => {
const user = userEvent.setup()
const { emitted } = await renderEmpty({ disabled: true })
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toBeUndefined()
})
it('shows uploading spinner and hides upload controls while processing', async () => {
await renderEmpty({
uploading: true
})
expect(screen.getByText('Uploading…')).toBeTruthy()
expect(screen.queryByText('Drag and drop videos here to upload')).toBeNull()
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
})
it('does not emit browse while uploading', async () => {
await renderEmpty({ uploading: true })
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
})
})

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
accept = 'video/*',
disabled = false,
uploading = false,
fill = false,
onDragOver,
onDragDrop
} = defineProps<{
accept?: string
disabled?: boolean
uploading?: boolean
fill?: boolean
onDragOver?: (event: DragEvent) => boolean
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
}>()
const emit = defineEmits<{
browse: []
upload: [files: File[]]
}>()
const { t } = useI18n()
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const isInteractionDisabled = computed(() => disabled || uploading)
function matchesAccept(file: File) {
if (!accept || accept === '*/*') return true
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim()
if (trimmed.endsWith('/*')) {
return file.type.startsWith(trimmed.slice(0, -1))
}
return file.type === trimmed
})
}
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (files, event) => {
event?.stopPropagation()
if (isInteractionDisabled.value) return
if (onDragDrop && event) {
void Promise.resolve(onDragDrop(event)).catch(() => {})
} else {
const droppedFiles =
files && files.length > 0
? files
: Array.from(event?.dataTransfer?.files ?? [])
const accepted = droppedFiles.filter(matchesAccept)
if (accepted.length) emit('upload', accepted)
}
canAcceptDrop.value = false
},
onOver: (_, event) => {
if (isInteractionDisabled.value) {
canAcceptDrop.value = false
return
}
if (onDragOver && event) {
canAcceptDrop.value = onDragOver(event)
return
}
const items = event?.dataTransfer?.items
canAcceptDrop.value = items
? Array.from(items).some(
(item) => item.kind === 'file' && matchesAcceptType(item.type)
)
: false
},
onLeave: () => {
canAcceptDrop.value = false
}
})
function matchesAcceptType(type: string) {
if (!accept || accept === '*/*') return true
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim()
if (trimmed.endsWith('/*')) {
return type.startsWith(trimmed.slice(0, -1))
}
return type === trimmed
})
}
const isHovered = computed(
() =>
!isInteractionDisabled.value && canAcceptDrop.value && isOverDropZone.value
)
function handleBrowseClick() {
if (isInteractionDisabled.value) return
emit('browse')
}
</script>
<template>
<div
ref="dropZoneRef"
data-testid="media-upload-empty"
:class="
cn(
'flex min-h-75 w-full min-w-75 flex-col items-center justify-center gap-0 rounded-lg border border-dashed border-node-component-border bg-node-component-surface px-6 py-8 transition-colors',
fill && 'size-full flex-1',
isHovered &&
'border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<template v-if="uploading">
<Loader size="md" variant="loader-circle" />
<p class="text-sm text-muted-foreground">
{{ t('loadVideoTrim.uploading') }}
</p>
</template>
<template v-else>
<i
class="icon-[lucide--upload] size-8 text-muted-foreground"
aria-hidden="true"
/>
<p class="max-w-48 text-center text-sm/snug text-muted-foreground">
{{ t('loadVideoTrim.dragAndDropVideos') }}
</p>
<Button
variant="inverted"
size="lg"
class="min-w-40"
:disabled="disabled"
data-testid="media-upload-browse-button"
@click="handleBrowseClick"
>
{{ t('loadVideoTrim.uploadFromDevice') }}
</Button>
</template>
</div>
</template>

View File

@@ -0,0 +1,367 @@
/* eslint-disable testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
const { activeHandle } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: createRef } = require('vue')
return {
activeHandle: createRef(null) as Ref<'min' | 'max' | 'midpoint' | null>
}
})
vi.mock('@/composables/useRangeEditor', () => ({
useRangeEditor: () => ({
startDrag: vi.fn(),
activeHandle
})
}))
import type { ComponentProps } from 'vue-component-type-helpers'
import { fireEvent, render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import VideoFilmstripTrim from './VideoFilmstripTrim.vue'
import { timelineInsetLeftStyle } from './timelineInsetStyle'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
loadVideoTrim: {
play: 'Play',
pause: 'Pause',
loadingFilmstrip: 'Loading filmstrip…',
adjustStartFrame: 'Adjust start frame',
adjustEndFrame: 'Adjust end frame'
}
}
}
})
type FilmstripProps = ComponentProps<typeof VideoFilmstripTrim>
function expectedFrameAt(clientX: number, width = 200, frameMax = 100) {
const contentWidth = Math.max(width - 32, 1)
const norm = Math.min(Math.max((clientX - 16) / contentWidth, 0), 1)
return Math.round(norm * frameMax)
}
function renderFilmstrip(props: FilmstripProps) {
return render(VideoFilmstripTrim, {
props,
global: {
plugins: [i18n]
}
})
}
function mockTrackRect() {
const track = screen.getByTestId('trim-track')
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 200,
height: 64,
right: 200,
bottom: 64,
x: 0,
y: 0,
toJSON: () => ({})
})
return track
}
describe('VideoFilmstripTrim', () => {
beforeEach(() => {
activeHandle.value = null
})
it('insets the filmstrip track by handle width on each side', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 99,
playheadFrame: 0,
disabled: false
})
const filmstrip = screen.getByTestId('filmstrip-track')
expect(filmstrip.style.left).toBe('16px')
expect(filmstrip.style.right).toBe('16px')
})
it('prevents filmstrip thumbnails from being dragged', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 99,
playheadFrame: 0,
disabled: false
})
expect(
screen.getByTestId('filmstrip-thumbnail').getAttribute('draggable')
).toBe('false')
})
it('shows whole frame number in tooltip while dragging end handle', () => {
activeHandle.value = 'max'
renderFilmstrip({
totalFrames: 401,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 400,
playheadFrame: 0,
disabled: false
})
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('400')
expect(timelineInsetLeftStyle(1).left).toBe(
'calc(1 * (100% - 2rem) + 1rem)'
)
})
it('shows whole frame number in tooltip while dragging start handle', () => {
activeHandle.value = 'min'
renderFilmstrip({
totalFrames: 401,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 120,
endFrame: 400,
playheadFrame: 120,
disabled: false
})
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('120')
expect(timelineInsetLeftStyle(120 / 400).left).toBe(
'calc(0.3 * (100% - 2rem) + 1rem)'
)
})
it('positions the playhead on the timeline', () => {
renderFilmstrip({
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 50,
disabled: false
})
expect(screen.getByTestId('playhead')).toBeTruthy()
expect(timelineInsetLeftStyle(50 / 100).left).toBe(
'calc(0.5 * (100% - 2rem) + 1rem)'
)
})
it('scrubs to the clicked frame on the filmstrip', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
expect(playheadFrame.value).toBe(expectedFrameAt(100))
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
})
it('clamps scrubbing to the trim selection when trim is enabled', async () => {
const playheadFrame = ref(50)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 50,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 20, button: 0 })
expect(playheadFrame.value).toBe(10)
expect(emitted().scrub).toEqual([[10]])
await fireEvent.pointerDown(track, { clientX: 180, button: 0 })
expect(playheadFrame.value).toBe(80)
expect(emitted().scrub).toEqual([[10], [80]])
})
it('updates playhead while dragging across the filmstrip', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
track.setPointerCapture = vi.fn()
await fireEvent.pointerDown(track, { clientX: 40, button: 0, pointerId: 1 })
await fireEvent.pointerMove(track, {
clientX: 120,
button: 0,
pointerId: 1
})
expect(playheadFrame.value).toBe(expectedFrameAt(120))
expect(emitted().scrub).toEqual([
[expectedFrameAt(40)],
[expectedFrameAt(120)]
])
})
it('shows the frame number in a tooltip while scrubbing', async () => {
const playheadFrame = ref(0)
render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
track.setPointerCapture = vi.fn()
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
await fireEvent.pointerDown(track, {
clientX: 120,
button: 0,
pointerId: 1
})
expect(screen.getByTestId('scrub-tooltip')).toHaveTextContent(
String(expectedFrameAt(120))
)
await fireEvent.pointerUp(track, { pointerId: 1 })
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
})
it('renders trim handles when enabled', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
disabled: false
})
expect(screen.getByTestId('handle-start')).toBeTruthy()
expect(screen.getByTestId('handle-end')).toBeTruthy()
})
it('hides trim handles when disabled', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
disabled: true
})
expect(screen.queryByTestId('handle-start')).toBeNull()
expect(screen.queryByTestId('handle-end')).toBeNull()
})
it('hides trim selection UI when trim is toggled off', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
trimEnabled: false
})
expect(screen.getByTestId('playhead')).toBeTruthy()
expect(screen.getByTestId('filmstrip-track').style.left).toBe('16px')
expect(screen.getByTestId('filmstrip-track').style.right).toBe('16px')
expect(screen.queryByTestId('handle-start')).toBeNull()
expect(screen.queryByTestId('handle-end')).toBeNull()
})
it('scrubs across the full timeline when trim is toggled off', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 0,
trimEnabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
expect(playheadFrame.value).toBe(expectedFrameAt(100))
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
})
})

View File

@@ -0,0 +1,365 @@
<template>
<div class="flex h-16 w-full items-stretch gap-px" @pointerdown.stop>
<button
type="button"
:class="
cn(
'flex w-14 shrink-0 items-center justify-center rounded-l-lg border-none bg-component-node-widget-background px-4 text-muted-foreground',
!disabled &&
'cursor-pointer hover:bg-component-node-widget-background-hovered',
disabled && 'cursor-default opacity-50'
)
"
:disabled="disabled"
:aria-label="
isPlaying ? t('loadVideoTrim.pause') : t('loadVideoTrim.play')
"
@click="togglePlay"
>
<i
:class="
cn(
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]',
!isPlaying && 'ml-0.5',
'size-5'
)
"
/>
</button>
<div
ref="trackRef"
data-testid="trim-track"
:class="
cn(
'relative min-w-0 flex-1 rounded-r-lg bg-component-node-widget-background',
isDraggingTimeline ? 'cursor-ew-resize' : 'cursor-default'
)
"
@pointerdown.stop="startScrubDrag"
@contextmenu.prevent.stop
>
<div
v-if="isScrubDragging"
data-testid="scrub-tooltip"
class="pointer-events-none absolute bottom-full z-30 mb-1 flex -translate-x-1/2 flex-col items-center"
:style="playheadStyle"
>
<span
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
>
{{ playheadFrame }}
</span>
<span
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
/>
</div>
<div
v-if="trimEnabled && (activeHandle === 'min' || activeHandle === 'max')"
data-testid="trim-handle-tooltip"
class="pointer-events-none absolute bottom-full z-10 mb-1 flex -translate-x-1/2 flex-col items-center"
:style="activeHandleTooltipStyle"
>
<span
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
>
{{ activeHandleFrame }}
</span>
<span
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
/>
</div>
<div
data-testid="filmstrip-track"
class="pointer-events-none absolute top-2 flex h-12 items-stretch overflow-hidden"
:style="{
left: `${HANDLE_WIDTH_PX}px`,
right: `${HANDLE_WIDTH_PX}px`
}"
aria-hidden="true"
>
<img
v-for="(thumbnail, index) in thumbnails"
:key="index"
data-testid="filmstrip-thumbnail"
:src="thumbnail"
alt=""
draggable="false"
class="h-full w-auto shrink-0 select-none"
/>
<div
v-if="isFilmstripLoading"
class="flex size-full items-stretch gap-px overflow-hidden"
data-testid="filmstrip-skeleton"
:aria-busy="true"
:aria-label="t('loadVideoTrim.loadingFilmstrip')"
>
<Skeleton
v-for="index in FILMSTRIP_SAMPLE_COUNT"
:key="index"
class="h-full min-w-10 flex-1 rounded-none"
/>
</div>
</div>
<div
v-if="trimEnabled && startNorm > 0"
class="pointer-events-none absolute inset-y-0 left-0 bg-black/50"
:style="leftDimStyle"
/>
<div
v-if="trimEnabled && endNorm < 1"
class="pointer-events-none absolute inset-y-0 right-0 bg-black/50"
:style="rightDimStyle"
/>
<div
v-if="trimEnabled"
class="pointer-events-none absolute inset-y-0 flex"
:style="selectionStyle"
>
<button
v-if="!disabled && totalFrames > 1"
type="button"
data-testid="handle-start"
:class="
cn(
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
'items-center justify-center bg-video-trim-selection-background',
'rounded-l-lg border-none p-0'
)
"
:aria-label="t('loadVideoTrim.adjustStartFrame')"
@pointerdown.stop="startDrag('min', $event)"
>
<span class="h-4 w-px rounded-full bg-secondary-background" />
</button>
<div class="flex min-w-0 flex-1 flex-col">
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
<div class="h-12 shrink-0" />
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
</div>
<button
v-if="!disabled && totalFrames > 1"
type="button"
data-testid="handle-end"
:class="
cn(
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
'items-center justify-center bg-video-trim-selection-background',
'rounded-r-lg border-none p-0'
)
"
:aria-label="t('loadVideoTrim.adjustEndFrame')"
@pointerdown.stop="startDrag('max', $event)"
>
<span class="h-4 w-px rounded-full bg-secondary-background" />
</button>
</div>
<div
data-testid="playhead"
class="absolute top-2 z-20 flex h-12 w-3 -translate-x-1/2 cursor-ew-resize touch-none items-stretch justify-center"
:style="playheadStyle"
@pointerdown.stop="startScrubDrag"
>
<div
class="pointer-events-none w-0.5 bg-video-trim-playhead-background"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, toRef, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { clamp } from 'es-toolkit'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { timelineInsetLeftStyle } from '@/components/video/timelineInsetStyle'
import { FILMSTRIP_SAMPLE_COUNT } from '@/composables/video/useVideoFilmstrip'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import { denormalize } from '@/utils/mathUtil'
import { cn } from '@comfyorg/tailwind-utils'
const HANDLE_WIDTH_PX = 16
const {
totalFrames,
thumbnails,
disabled = false,
trimEnabled = true
} = defineProps<{
totalFrames: number
thumbnails: string[]
disabled?: boolean
trimEnabled?: boolean
}>()
const startFrame = defineModel<number>('startFrame', { required: true })
const endFrame = defineModel<number>('endFrame', { required: true })
const playheadFrame = defineModel<number>('playheadFrame', { required: true })
const isPlaying = defineModel<boolean>('isPlaying', { default: false })
const emit = defineEmits<{
scrub: [frame: number]
}>()
const { t } = useI18n()
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const isScrubDragging = ref(false)
const frameMax = computed(() => Math.max(totalFrames - 1, 0))
const rangeValue = computed<RangeValue>({
get: () => ({
min: startFrame.value,
max: endFrame.value
}),
set: (value) => {
startFrame.value = Math.round(value.min)
endFrame.value = Math.round(value.max)
}
})
const contentInsetX = computed(() => HANDLE_WIDTH_PX)
const { startDrag, activeHandle } = useRangeEditor({
trackRef,
modelValue: rangeValue,
valueMin: toRef(() => 0),
valueMax: frameMax,
showMidpoint: toRef(() => false),
contentInsetX
})
const isDraggingTimeline = computed(
() => isScrubDragging.value || activeHandle.value !== null
)
const isFilmstripLoading = computed(() => thumbnails.length === 0)
const trimSelectionBarClass = computed(() =>
isFilmstripLoading.value
? 'bg-component-node-widget-background'
: 'bg-video-trim-selection-background'
)
function pointerToFrame(event: PointerEvent) {
const el = trackRef.value
if (!el) return playheadFrame.value
const rect = el.getBoundingClientRect()
const inset = HANDLE_WIDTH_PX
const contentWidth = Math.max(rect.width - 2 * inset, 1)
const normalized = clamp(
(event.clientX - rect.left - inset) / contentWidth,
0,
1
)
return Math.round(denormalize(normalized, 0, frameMax.value))
}
const scrubFrameMin = computed(() => (trimEnabled ? startFrame.value : 0))
const scrubFrameMax = computed(() =>
trimEnabled ? endFrame.value : frameMax.value
)
function scrubToFrame(frame: number) {
const clamped = clamp(frame, scrubFrameMin.value, scrubFrameMax.value)
playheadFrame.value = clamped
emit('scrub', clamped)
}
function updateScrubFromPointer(event: PointerEvent) {
const frame = pointerToFrame(event)
if (frame === playheadFrame.value) return
scrubToFrame(frame)
}
let cleanupScrubDrag: (() => void) | null = null
function startScrubDrag(event: PointerEvent) {
if (disabled || totalFrames <= 1 || event.button !== 0) return
const el = trackRef.value
if (!el) return
cleanupScrubDrag?.()
isScrubDragging.value = true
scrubToFrame(pointerToFrame(event))
el.setPointerCapture(event.pointerId)
const onMove = (moveEvent: PointerEvent) => {
updateScrubFromPointer(moveEvent)
}
const endDrag = () => {
isScrubDragging.value = false
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', endDrag)
el.removeEventListener('lostpointercapture', endDrag)
cleanupScrubDrag = null
}
cleanupScrubDrag = endDrag
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', endDrag)
el.addEventListener('lostpointercapture', endDrag)
}
onBeforeUnmount(() => {
isScrubDragging.value = false
cleanupScrubDrag?.()
})
const startNorm = computed(() =>
frameMax.value <= 0 ? 0 : startFrame.value / frameMax.value
)
const endNorm = computed(() =>
frameMax.value <= 0 ? 1 : endFrame.value / frameMax.value
)
const playheadNorm = computed(() =>
frameMax.value <= 0 ? 0 : playheadFrame.value / frameMax.value
)
const playheadStyle = computed(() => timelineInsetLeftStyle(playheadNorm.value))
const leftDimStyle = computed(() => ({
width: `calc(${startNorm.value} * (100% - 2rem))`
}))
const rightDimStyle = computed(() => ({
width: `calc(${1 - endNorm.value} * (100% - 2rem))`
}))
const selectionStyle = computed(() => ({
left: `calc(${startNorm.value} * (100% - 2rem))`,
width: `calc((${endNorm.value} - ${startNorm.value}) * (100% - 2rem) + 2rem)`
}))
const activeHandleFrame = computed(() => {
if (activeHandle.value === 'min') return startFrame.value
if (activeHandle.value === 'max') return endFrame.value
return 0
})
const activeHandleTooltipStyle = computed(() => {
const norm = activeHandle.value === 'min' ? startNorm.value : endNorm.value
return timelineInsetLeftStyle(norm)
})
function togglePlay() {
if (disabled) return
isPlaying.value = !isPlaying.value
}
</script>

View File

@@ -0,0 +1,5 @@
export function timelineInsetLeftStyle(normalized: number) {
return {
left: `calc(${normalized} * (100% - 2rem) + 1rem)`
}
}

View File

@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
): ComfyNodeDefImpl | undefined {
const name = tile.nodeName
if (!name) return undefined
const byName = nodeDefStore.allNodeDefsByName[name]
if (byName) return byName
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
: name
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
return nodeDefStore.allNodeDefsByName[name]
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
}
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {

View File

@@ -50,6 +50,7 @@ interface HarnessOptions {
valueMax?: number
showMidpoint?: boolean
track?: HTMLElement | null
contentInsetX?: number
}
interface Harness {
@@ -72,6 +73,7 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
const valueMin = ref(opts.valueMin ?? 0)
const valueMax = ref(opts.valueMax ?? 100)
const showMidpoint = ref(opts.showMidpoint ?? true)
const contentInsetX = ref(opts.contentInsetX ?? 0)
let api: ReturnType<typeof useRangeEditor> | undefined
const TestComponent = defineComponent({
@@ -81,7 +83,8 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
modelValue,
valueMin,
valueMax,
showMidpoint
showMidpoint,
contentInsetX
})
return () => null
}
@@ -323,4 +326,44 @@ describe('useRangeEditor', () => {
expect.arrayContaining(['pointermove', 'pointerup', 'lostpointercapture'])
)
})
it('maps pointer at content inset to valueMin when contentInsetX is set', () => {
harness = mountRangeEditor({
initial: { min: 20, max: 80, midpoint: 0.5 },
valueMin: 0,
valueMax: 100,
showMidpoint: false,
contentInsetX: 16
})
harness.api.startDrag(
'min',
createPointerEvent('pointerdown', { clientX: 16 })
)
harness.trackRef.value!.dispatchEvent(
createPointerEvent('pointermove', { clientX: 16 })
)
expect(harness.modelValue.value.min).toBe(0)
})
it('maps pointer at right content inset to valueMax when contentInsetX is set', () => {
harness = mountRangeEditor({
initial: { min: 0, max: 80, midpoint: 0.5 },
valueMin: 0,
valueMax: 100,
showMidpoint: false,
contentInsetX: 16
})
harness.api.startDrag(
'max',
createPointerEvent('pointerdown', { clientX: 184 })
)
harness.trackRef.value!.dispatchEvent(
createPointerEvent('pointermove', { clientX: 184 })
)
expect(harness.modelValue.value.max).toBe(100)
})
})

View File

@@ -14,6 +14,7 @@ interface UseRangeEditorOptions {
valueMin: Ref<number>
valueMax: Ref<number>
showMidpoint: Ref<boolean>
contentInsetX?: Ref<number>
}
export function useRangeEditor({
@@ -21,7 +22,8 @@ export function useRangeEditor({
modelValue,
valueMin,
valueMax,
showMidpoint
showMidpoint,
contentInsetX
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(null)
let cleanupDrag: (() => void) | null = null
@@ -30,7 +32,13 @@ export function useRangeEditor({
const el = trackRef.value
if (!el) return valueMin.value
const rect = el.getBoundingClientRect()
const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1)
const inset = contentInsetX?.value ?? 0
const contentWidth = Math.max(rect.width - 2 * inset, 1)
const normalized = clamp(
(e.clientX - rect.left - inset) / contentWidth,
0,
1
)
return denormalize(normalized, valueMin.value, valueMax.value)
}
@@ -108,6 +116,7 @@ export function useRangeEditor({
return {
handleTrackPointerDown,
startDrag
startDrag,
activeHandle
}
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { parseMp4AverageFrameRate } from './probeVideoFrameRate'
function writeUint32(value: number): Uint8Array {
const bytes = new Uint8Array(4)
new DataView(bytes.buffer).setUint32(0, value)
return bytes
}
function writeBox(type: string, content: Uint8Array): Uint8Array {
const box = new Uint8Array(8 + content.length)
box.set(writeUint32(8 + content.length), 0)
for (let index = 0; index < 4; index++) {
box[4 + index] = type.charCodeAt(index)
}
box.set(content, 8)
return box
}
function concatBoxes(...boxes: Uint8Array[]): Uint8Array {
const totalLength = boxes.reduce((sum, box) => sum + box.length, 0)
const merged = new Uint8Array(totalLength)
let offset = 0
for (const box of boxes) {
merged.set(box, offset)
offset += box.length
}
return merged
}
function createVideoTrackBox(
sampleCount: number,
timescale: number
): Uint8Array {
const handler = writeBox(
'hdlr',
concatBoxes(
writeUint32(0),
writeUint32(0),
new Uint8Array([0x76, 0x69, 0x64, 0x65])
)
)
const mediaHeader = writeBox(
'mdhd',
concatBoxes(
writeUint32(0),
writeUint32(0),
writeUint32(0),
writeUint32(timescale),
writeUint32(timescale * 10)
)
)
const sampleSizes = writeBox(
'stsz',
concatBoxes(writeUint32(0), writeUint32(0), writeUint32(sampleCount))
)
const media = writeBox('mdia', concatBoxes(mediaHeader, sampleSizes, handler))
return writeBox('trak', concatBoxes(media))
}
describe('parseMp4AverageFrameRate', () => {
it('derives average frame rate from video track sample count and duration', () => {
const moov = writeBox('moov', createVideoTrackBox(240, 24))
const data = concatBoxes(moov)
expect(parseMp4AverageFrameRate(data, 10)).toBe(24)
})
it('returns undefined when moov metadata is missing', () => {
expect(parseMp4AverageFrameRate(new Uint8Array([0, 0, 0, 0]), 10)).toBe(
undefined
)
})
})

View File

@@ -0,0 +1,198 @@
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
const PROBE_CHUNK_BYTES = 512 * 1024
const MAX_FRAME_RATE = 240
interface BoxRange {
type: string
start: number
end: number
}
function readUint32(data: Uint8Array, offset: number): number {
if (offset + 4 > data.length) return 0
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return view.getUint32(offset)
}
function readBoxType(data: Uint8Array, offset: number): string {
if (offset + 4 > data.length) return ''
return String.fromCharCode(
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3]
)
}
function* iterateBoxes(
data: Uint8Array,
start: number,
end: number
): Generator<BoxRange> {
let pos = start
while (pos + 8 <= end) {
let size = readUint32(data, pos)
const type = readBoxType(data, pos + 4)
let headerSize = 8
if (size === 1) {
if (pos + 16 > end) return
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
size = Number(view.getBigUint64(pos + 8))
headerSize = 16
}
if (size < headerSize) return
const boxEnd = pos + size
if (boxEnd > end) return
yield { type, start: pos + headerSize, end: boxEnd }
pos = boxEnd
}
}
function findBox(
data: Uint8Array,
start: number,
end: number,
type: string
): BoxRange | undefined {
for (const box of iterateBoxes(data, start, end)) {
if (box.type === type) return box
}
return undefined
}
function findBoxDeep(
data: Uint8Array,
root: BoxRange,
type: string
): BoxRange | undefined {
const direct = findBox(data, root.start, root.end, type)
if (direct) return direct
for (const child of iterateBoxes(data, root.start, root.end)) {
const nested = findBoxDeep(data, child, type)
if (nested) return nested
}
return undefined
}
function isVideoTrack(data: Uint8Array, trak: BoxRange): boolean {
const handler = findBoxDeep(data, trak, 'hdlr')
if (!handler || handler.start + 12 > handler.end) return false
return readBoxType(data, handler.start + 8) === 'vide'
}
function readUint64(data: Uint8Array, offset: number): number {
if (offset + 8 > data.length) return 0
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return Number(view.getBigUint64(offset))
}
function frameRateFromTrack(
data: Uint8Array,
trak: BoxRange,
durationSeconds: number
): number | undefined {
const mediaHeader = findBoxDeep(data, trak, 'mdhd')
const sampleSizes = findBoxDeep(data, trak, 'stsz')
if (!mediaHeader || !sampleSizes) return undefined
const version = data[mediaHeader.start]
let timescale: number
let mediaDurationTicks: number
if (version === 1) {
timescale = readUint32(data, mediaHeader.start + 20)
mediaDurationTicks = readUint64(data, mediaHeader.start + 24)
} else {
timescale = readUint32(data, mediaHeader.start + 12)
mediaDurationTicks = readUint32(data, mediaHeader.start + 16)
}
const sampleCount = readUint32(data, sampleSizes.start + 8)
if (timescale <= 0 || sampleCount <= 0) return undefined
const trackDurationSeconds =
mediaDurationTicks > 0 ? mediaDurationTicks / timescale : durationSeconds
const duration =
trackDurationSeconds > 0 ? trackDurationSeconds : durationSeconds
if (duration <= 0) return undefined
const frameRate = sampleCount / duration
if (frameRate <= 0 || frameRate > MAX_FRAME_RATE) return undefined
return frameRate
}
export function parseMp4AverageFrameRate(
data: Uint8Array,
durationSeconds: number
): number | undefined {
if (durationSeconds <= 0) return undefined
const movie = findBox(data, 0, data.length, 'moov')
if (!movie) return undefined
for (const track of iterateBoxes(data, movie.start, movie.end)) {
if (track.type !== 'trak' || !isVideoTrack(data, track)) continue
const frameRate = frameRateFromTrack(data, track, durationSeconds)
if (frameRate != null) return frameRate
}
return undefined
}
async function fetchRange(
url: string,
start: number,
end: number
): Promise<ArrayBuffer | undefined> {
try {
const response = await fetch(url, {
headers: { Range: `bytes=${start}-${end}` }
})
if (response.status !== 206) return undefined
return await response.arrayBuffer()
} catch {
return undefined
}
}
export async function probeVideoFrameRate(
url: string,
durationSeconds: number,
byteSize?: number
): Promise<number | undefined> {
if (durationSeconds <= 0) return undefined
const resolvedByteSize = byteSize ?? (await fetchHttpResourceByteSize(url))
const chunks: Uint8Array[] = []
const leading = await fetchRange(url, 0, PROBE_CHUNK_BYTES - 1)
if (leading) chunks.push(new Uint8Array(leading))
if (resolvedByteSize != null && resolvedByteSize > PROBE_CHUNK_BYTES) {
const trailingStart = Math.max(0, resolvedByteSize - PROBE_CHUNK_BYTES)
const trailing = await fetchRange(
url,
trailingStart,
Math.max(trailingStart, resolvedByteSize - 1)
)
if (trailing) chunks.push(new Uint8Array(trailing))
}
for (const chunk of chunks) {
const frameRate = parseMp4AverageFrameRate(chunk, durationSeconds)
if (frameRate != null) return frameRate
}
return undefined
}

View File

@@ -0,0 +1,91 @@
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import {
useLoadVideoPreview,
nodeHasLoadVideoPreview
} from './useLoadVideoPreview'
const { getNodeImageUrlsMock } = vi.hoisted(() => ({
getNodeImageUrlsMock: vi.fn<(node: unknown) => string[] | undefined>(
() => undefined
)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
nodeOutputs: {},
getNodeImageUrls: getNodeImageUrlsMock
})
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: () => ''
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `https://example.test${path}`
}
}))
describe('useLoadVideoPreview', () => {
it('falls back to the file widget value when node outputs are unavailable', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
const node = computed(() => ({
widgets: [{ name: 'file', value: 'clip.mp4' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBe(
'https://example.test/view?filename=clip.mp4&subfolder=&type=input'
)
})
it('prefers node output preview urls over the file widget fallback', () => {
getNodeImageUrlsMock.mockReturnValue([
'https://example.test/view?filename=from-output.mp4'
])
const node = computed(() => ({
widgets: [{ name: 'file', value: 'clip.mp4' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBe(
'https://example.test/view?filename=from-output.mp4'
)
})
it('detects preview availability from the file widget fallback', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
expect(
nodeHasLoadVideoPreview({
widgets: [{ name: 'file', value: 'clip.mp4' }]
} as never)
).toBe(true)
})
it('ignores remote widget placeholder values', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
const node = computed(() => ({
widgets: [{ name: 'file', value: 'Loading...' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBeUndefined()
expect(
nodeHasLoadVideoPreview({
widgets: [{ name: 'file', value: 'Loading...' }]
} as never)
).toBe(false)
})
})

View File

@@ -0,0 +1,75 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { parseImageWidgetValue } from '@/utils/imageUtil'
const REMOTE_WIDGET_PLACEHOLDER = 'Loading...'
function isResolvableFileWidgetValue(raw: unknown): raw is string {
if (typeof raw !== 'string' || !raw || raw === REMOTE_WIDGET_PLACEHOLDER) {
return false
}
const { filename } = parseImageWidgetValue(raw)
return Boolean(filename)
}
function resolveVideoUrlFromFileWidget(node: LGraphNode): string | undefined {
const fileWidget = node.widgets?.find((widget) => widget.name === 'file')
const raw = fileWidget?.value
if (!isResolvableFileWidgetValue(raw)) return undefined
const { filename, subfolder, type } = parseImageWidgetValue(raw)
if (!filename) return undefined
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
export function nodeHasLoadVideoPreview(
node: LGraphNode | null | undefined
): boolean {
if (!node) return false
const nodeOutputStore = useNodeOutputStore()
if ((nodeOutputStore.getNodeImageUrls(node)?.length ?? 0) > 0) {
return true
}
return resolveVideoUrlFromFileWidget(node) !== undefined
}
export function useLoadVideoPreview(
node: ComputedRef<LGraphNode | null | undefined>
) {
const nodeOutputStore = useNodeOutputStore()
const widgetValueStore = useWidgetValueStore()
const videoUrl = computed(() => {
const currentNode = node.value
if (!currentNode) return undefined
void nodeOutputStore.nodeOutputs
const graphId = currentNode.graph?.rootGraph?.id
if (graphId) {
void widgetValueStore.getWidget(widgetId(graphId, currentNode.id, 'file'))
?.value
}
return (
nodeOutputStore.getNodeImageUrls(currentNode)?.[0] ??
resolveVideoUrlFromFileWidget(currentNode)
)
})
return { videoUrl }
}

View File

@@ -0,0 +1,198 @@
import { effectScope, nextTick, ref } from 'vue'
import type { EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
import {
DEFAULT_VIDEO_FPS,
FILMSTRIP_SAMPLE_COUNT,
useVideoFilmstrip
} from './useVideoFilmstrip'
vi.mock('@/composables/video/probeVideoFrameRate', () => ({
probeVideoFrameRate: vi.fn(async () => undefined)
}))
vi.mock('@/utils/httpResourceByteSize', () => ({
fetchHttpResourceByteSize: vi.fn(async () => undefined)
}))
type VideoListener = (event: Event) => void
class MockVideoElement {
preload = ''
muted = false
playsInline = false
crossOrigin = ''
duration = 10
videoWidth = 512
videoHeight = 512
src = ''
private listeners = new Map<string, Set<VideoListener>>()
set currentTime(_value: number) {
queueMicrotask(() => this.emit('seeked'))
}
addEventListener(type: string, listener: VideoListener, options?: boolean) {
if (options === true) {
const wrapped = (event: Event) => {
this.removeEventListener(type, wrapped)
listener(event)
}
this.getListeners(type).add(wrapped)
return
}
this.getListeners(type).add(listener)
}
removeEventListener(type: string, listener: VideoListener) {
this.getListeners(type).delete(listener)
}
load() {
this.src = ''
}
removeAttribute(name: string) {
if (name === 'src') this.src = ''
}
private getListeners(type: string) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
return this.listeners.get(type)!
}
emit(type: string) {
for (const listener of [...this.getListeners(type)]) {
listener(new Event(type))
}
}
}
function createMockCanvas(): HTMLCanvasElement {
return {
width: 0,
height: 0,
getContext: () => ({
drawImage: vi.fn()
}),
toDataURL: () => 'data:image/jpeg;base64,thumb'
} as unknown as HTMLCanvasElement
}
function installVideoMocks() {
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName === 'video') {
const video = new MockVideoElement()
queueMicrotask(() => video.emit('loadedmetadata'))
return video as unknown as HTMLVideoElement
}
if (tagName === 'canvas') {
return createMockCanvas()
}
return originalCreateElement(tagName)
})
}
describe('useVideoFilmstrip', () => {
let scope: EffectScope | undefined
function runWithScope<T>(fn: () => T): T {
scope = effectScope()
return scope.run(fn)!
}
afterEach(() => {
scope?.stop()
scope = undefined
vi.restoreAllMocks()
})
it('estimates total frames from duration and default fps', async () => {
installVideoMocks()
const videoUrl = ref('https://example.com/video.mp4')
const { totalFrames, duration, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(duration.value).toBe(10)
expect(totalFrames.value).toBe(Math.round(10 * DEFAULT_VIDEO_FPS))
})
it('clears state when url is removed', async () => {
installVideoMocks()
const videoUrl = ref<string | undefined>('https://example.com/video.mp4')
const { thumbnails, totalFrames, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
videoUrl.value = undefined
await nextTick()
expect(thumbnails.value).toEqual([])
expect(totalFrames.value).toBe(0)
expect(loading.value).toBe(false)
})
it('uses probed frame rate and file size when available', async () => {
installVideoMocks()
vi.mocked(probeVideoFrameRate).mockResolvedValueOnce(24)
vi.mocked(fetchHttpResourceByteSize).mockResolvedValueOnce(5 * 1024 * 1024)
const videoUrl = ref('https://example.com/video.mp4')
const { totalFrames, fps, fileSize, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(fps.value).toBe(24)
expect(totalFrames.value).toBe(240)
expect(fileSize.value).toBe(5 * 1024 * 1024)
})
it('samples the configured number of frames', async () => {
let seekCount = 0
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName === 'video') {
const video = new MockVideoElement()
video.addEventListener('seeked', () => {
seekCount += 1
})
queueMicrotask(() => video.emit('loadedmetadata'))
return video as unknown as HTMLVideoElement
}
if (tagName === 'canvas') {
return createMockCanvas()
}
return originalCreateElement(tagName)
})
const videoUrl = ref('https://example.com/video.mp4')
const { thumbnails, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl, {
sampleCount: FILMSTRIP_SAMPLE_COUNT
})
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(seekCount).toBe(FILMSTRIP_SAMPLE_COUNT)
expect(thumbnails.value).toHaveLength(FILMSTRIP_SAMPLE_COUNT)
})
})

View File

@@ -0,0 +1,206 @@
import { onScopeDispose, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
export const DEFAULT_VIDEO_FPS = 20
export const FILMSTRIP_SAMPLE_COUNT = 20
interface UseVideoFilmstripOptions {
fps?: number
sampleCount?: number
}
function waitForEvent(target: EventTarget, eventName: string): Promise<Event> {
return new Promise((resolve, reject) => {
const onSuccess = (event: Event) => {
cleanup()
resolve(event)
}
const onError = () => {
cleanup()
reject(new Error(`Failed to load ${eventName}`))
}
const cleanup = () => {
target.removeEventListener(eventName, onSuccess)
target.removeEventListener('error', onError)
}
target.addEventListener(eventName, onSuccess, { once: true })
target.addEventListener('error', onError, { once: true })
})
}
async function captureFrame(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D
): Promise<string> {
const width = video.videoWidth
const height = video.videoHeight
if (width <= 0 || height <= 0) return ''
canvas.width = width
canvas.height = height
context.drawImage(video, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', 0.7)
}
async function sampleFilmstripFrames(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
duration: number,
sampleCount: number
): Promise<string[]> {
const thumbnails: string[] = []
const lastIndex = Math.max(sampleCount - 1, 1)
for (let index = 0; index < sampleCount; index++) {
const time = sampleCount <= 1 ? 0 : (duration * index) / lastIndex
video.currentTime = Math.min(time, Math.max(duration - 0.001, 0))
await waitForEvent(video, 'seeked')
const thumbnail = await captureFrame(video, canvas, context)
if (thumbnail) thumbnails.push(thumbnail)
}
return thumbnails
}
export function useVideoFilmstrip(
videoUrl: Ref<string | undefined>,
options: UseVideoFilmstripOptions = {}
) {
const sampleCount = options.sampleCount ?? FILMSTRIP_SAMPLE_COUNT
const thumbnails = ref<string[]>([])
const duration = ref(0)
const totalFrames = ref(0)
const width = ref(0)
const height = ref(0)
const fps = ref(options.fps ?? DEFAULT_VIDEO_FPS)
const fileSize = ref<number | undefined>()
const loading = ref(false)
const error = ref<string | null>(null)
let activeLoadId = 0
function isLoadStale(loadId: number, url: string) {
return loadId !== activeLoadId || videoUrl.value !== url
}
async function loadVideo(url: string) {
const loadId = ++activeLoadId
loading.value = true
error.value = null
thumbnails.value = []
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
video.crossOrigin = 'anonymous'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
loading.value = false
error.value = 'Canvas is unavailable'
return
}
try {
video.src = url
await waitForEvent(video, 'loadedmetadata')
if (isLoadStale(loadId, url)) return
const videoDuration = Number.isFinite(video.duration) ? video.duration : 0
duration.value = videoDuration
width.value = video.videoWidth
height.value = video.videoHeight
const detectedFileSize = await fetchHttpResourceByteSize(url)
if (isLoadStale(loadId, url)) return
const detectedFrameRate = await probeVideoFrameRate(
url,
videoDuration,
detectedFileSize
)
if (isLoadStale(loadId, url)) return
fps.value = detectedFrameRate ?? options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = detectedFileSize
totalFrames.value = Math.max(Math.round(videoDuration * fps.value), 1)
const sampledThumbnails = await sampleFilmstripFrames(
video,
canvas,
context,
videoDuration,
sampleCount
)
if (isLoadStale(loadId, url)) return
thumbnails.value = sampledThumbnails
} catch (loadError) {
if (isLoadStale(loadId, url)) return
error.value =
loadError instanceof Error ? loadError.message : 'Failed to load video'
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
thumbnails.value = []
} finally {
if (loadId === activeLoadId) {
loading.value = false
}
video.removeAttribute('src')
video.load()
}
}
watch(
videoUrl,
(url) => {
if (!url) {
activeLoadId++
loading.value = false
error.value = null
thumbnails.value = []
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
return
}
void loadVideo(url)
},
{ immediate: true }
)
onScopeDispose(() => {
activeLoadId++
})
return {
thumbnails,
duration,
totalFrames,
width,
height,
fps,
fileSize,
loading,
error
}
}

View File

@@ -815,8 +815,10 @@ export class GroupNodeConfig {
* `configure`. The load-time migration unpacks each instance via
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
* result as a subgraph.
*
* @knipIgnoreUnusedButUsedByCustomNodes
*/
class GroupNodeHandler {
export class GroupNodeHandler {
node: LGraphNode
groupData: GroupNodeConfig

View File

@@ -13,6 +13,7 @@ import './imageCompare'
import './imageCrop'
// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB)
// The lazy loader triggers loading when a 3D node is used
import './loadVideoTrim'
import './load3dLazy'
import './maskeditor'
if (!isCloud) {

View File

@@ -0,0 +1,17 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useExtensionService } from '@/services/extensionService'
import { useVideoTrimWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useVideoTrimWidget'
useExtensionService().registerExtension({
name: 'Comfy.LoadVideoTrimPrototype',
nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'LoadVideo') return
node.hideOutputImages = true
node.setSize([Math.max(node.size[0], 350), node.size[1]])
useVideoTrimWidget(node)
}
})

View File

@@ -146,6 +146,7 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IVideoTrimWidget
| IBoundingBoxesWidget
| IColorsWidget
@@ -369,6 +370,12 @@ export interface RangeValue {
midpoint?: number
}
export interface VideoTrimValue {
trimEnabled: boolean
startFrame: number
endFrame: number
}
export interface IWidgetRangeOptions extends IWidgetOptions {
display?: 'plain' | 'gradient' | 'histogram'
gradient_stops?: ColorStop[]
@@ -387,6 +394,14 @@ export interface IRangeWidget extends IBaseWidget<
value: RangeValue
}
export interface IVideoTrimWidget extends IBaseWidget<
VideoTrimValue,
'videotrim'
> {
type: 'videotrim'
value: VideoTrimValue
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -0,0 +1,16 @@
import type { IVideoTrimWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class VideoTrimWidget
extends BaseWidget<IVideoTrimWidget>
implements IVideoTrimWidget
{
override type = 'videotrim' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Video Trim')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -25,6 +25,7 @@ import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { VideoTrimWidget } from './VideoTrimWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
@@ -64,6 +65,7 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
videotrim: VideoTrimWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
@@ -148,6 +150,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'videotrim':
return toClass(VideoTrimWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':

View File

@@ -3017,6 +3017,7 @@
"placeholderImage": "Select image...",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"browseAssetLibrary": "Browse asset library",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
@@ -4477,6 +4478,32 @@
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"loadVideoTrim": {
"trimVideo": "Trim Video",
"startFrame": "Start Frame",
"endFrame": "End Frame",
"setStartFrame": "Set start frame",
"setEndFrame": "Set end frame",
"duration": "Duration",
"frames": "Number of Frames",
"fileSize": "File Size",
"resolution": "{width} × {height}",
"play": "Play",
"pause": "Pause",
"dragAndDropVideos": "Drag and drop videos here to upload",
"uploadFromDevice": "Upload from device",
"uploading": "Uploading…",
"loadingVideo": "Loading video preview",
"loadingFilmstrip": "Loading filmstrip…",
"adjustStartFrame": "Adjust start frame",
"adjustEndFrame": "Adjust end frame",
"durationZero": "0s",
"durationSeconds": "{count}s",
"fileSizeUnknown": "—",
"fileSizeBytes": "{count} B",
"fileSizeKilobytes": "{count} KB",
"fileSizeMegabytes": "{count} MB"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",

View File

@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const trackSettingChanged = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
const mockGet = vi.fn()
const mockSet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
template: '<div data-testid="form-item-stub" />'
})
describe('SettingItem (telemetry UI tracking)', () => {
describe('SettingItem', () => {
beforeEach(() => {
vi.clearAllMocks()
emitFormValue = null
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
})
}
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
it('persists setting updates through the setting store', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
name: 'Visible Setting',
type: 'text',
defaultValue: 'default'
}
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
mockGet.mockReturnValue('default')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
await flushPromises()
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith(
expect.objectContaining({
setting_id: 'main.sub.setting.name',
previous_value: 'default',
new_value: 'normalized'
})
)
})
it('does not track telemetry when normalized value does not change', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
type: 'text',
defaultValue: 'same'
}
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
emitFormValue!('same')
await flushPromises()
expect(trackSettingChanged).not.toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
})
})

View File

@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingOption, SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { Settings } from '@/schemas/apiSchema'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = async <K extends keyof Settings>(
newValue: Settings[K]
) => {
const telemetry = useTelemetry()
const settingId = props.setting.id
const previousValue = settingValue.value
await settingStore.set(settingId, newValue)
const normalizedValue = settingStore.get(settingId)
if (previousValue !== normalizedValue) {
telemetry?.trackSettingChanged({
setting_id: settingId,
previous_value: previousValue,
new_value: normalizedValue
})
}
await settingStore.set(props.setting.id, newValue)
}
</script>

View File

@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
telemetry: { trackChanges: true, includeValues: true },
migrateDeprecatedValue(val: unknown) {
const value = val as string
// Legacy custom palettes were prefixed with 'custom_'

View File

@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const { trackSettingChanged } = vi.hoisted(() => ({
trackSettingChanged: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
// Set the same value again, it should not trigger onChange
await store.set('test.setting', 'newvalue')
expect(onChangeMock).toHaveBeenCalledTimes(2)
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
// Set a different value, it should trigger onChange
await store.set('test.setting', 'differentvalue')
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
)
})
it('tracks visible settings with values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting',
previous_value: 'default',
new_value: 'newvalue'
})
})
it('does not track hidden settings by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('does not track visible settings that opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('tracks visible settings without values when values opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { includeValues: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
})
it('tracks hidden settings that opt in, without shipping values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
// Setting the same value again is a no-op and should not re-emit
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
})
it('ships previous/new values when the setting opts into includeValues', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
await store.set('Comfy.ColorPalette', 'light')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('does not track telemetry when persistence fails', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
'failed'
)
expect(trackSettingChanged).not.toHaveBeenCalled()
})
describe('object mutation prevention', () => {
beforeEach(() => {
const setting: SettingParams = {
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
expect(api.storeSetting).not.toHaveBeenCalled()
})
it('tracks only the settings in a batch that opt in', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
await store.setMany({
'Comfy.ColorPalette': 'light',
'Comfy.Release.Version': '1.0.0'
})
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('should skip unchanged values', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
await store.setMany({ 'Comfy.Release.Version': 'existing' })
expect(api.storeSettings).not.toHaveBeenCalled()
expect(trackSettingChanged).not.toHaveBeenCalled()
})
})
})

View File

@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
interface AppliedSetting<TValue> {
previousValue: TValue
newValue: TValue
}
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
@@ -45,6 +52,28 @@ function onChange(
}
}
function settingChangedEvent<K extends keyof Settings>(
setting: SettingParams | undefined,
key: K,
applied: AppliedSetting<Settings[K]>
): SettingChangedMetadata | undefined {
if (!setting) return undefined
const telemetry = setting.telemetry
const isVisible = setting.type !== 'hidden'
const trackChanges = telemetry?.trackChanges ?? isVisible
if (!trackChanges) return undefined
const includeValues = telemetry?.includeValues ?? isVisible
return includeValues
? {
setting_id: key,
previous_value: applied.previousValue,
new_value: applied.newValue
}
: { setting_id: key }
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Partial<Settings>>({})
const settingsById = ref<Record<string, SettingParams>>({})
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
function applySettingLocally<K extends keyof Settings>(
key: K,
value: Settings[K]
): Settings[K] | undefined {
): AppliedSetting<Settings[K]> | undefined {
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
if (newValue === oldValue) return undefined
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
return newValue as Settings[K]
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
return {
previousValue: oldValue,
newValue: typedNewValue
}
}
/**
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
const applied = applySettingLocally(key, value)
if (applied === undefined) return
await api.storeSetting(key, applied)
await api.storeSetting(key, applied.newValue)
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) useTelemetry()?.trackSettingChanged(event)
}
/**
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
*/
async function setMany(settings: Partial<Settings>) {
const updatedSettings: Partial<Settings> = {}
const telemetryEvents: SettingChangedMetadata[] = []
for (const key of Object.keys(settings) as (keyof Settings)[]) {
const applied = applySettingLocally(
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
settings[key] as Settings[typeof key]
)
if (applied !== undefined) {
updatedSettings[key] = applied
updatedSettings[key] = applied.newValue
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) telemetryEvents.push(event)
}
}
if (Object.keys(updatedSettings).length > 0) {
await api.storeSettings(updatedSettings)
const telemetry = useTelemetry()
for (const event of telemetryEvents) {
telemetry?.trackSettingChanged(event)
}
}
}

View File

@@ -26,11 +26,22 @@ export interface SettingOption {
value?: string | number
}
type SettingTelemetryOptions =
| {
trackChanges: false
includeValues?: never
}
| {
trackChanges?: true
includeValues?: boolean
}
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?(newValue: TValue, oldValue?: TValue): void
telemetry?: SettingTelemetryOptions
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.

View File

@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
@@ -635,7 +635,7 @@ describe('PostHogTelemetryProvider', () => {
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
it('captures legacy page view event with page_name property', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -645,9 +645,13 @@ describe('PostHogTelemetryProvider', () => {
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
'$pageview',
expect.anything()
)
})
it('forwards additional metadata', async () => {
it('forwards additional metadata to legacy page view event', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -660,6 +664,20 @@ describe('PostHogTelemetryProvider', () => {
{ page_name: 'workflow_editor', path: '/workflows/123' }
)
})
it('queues legacy page view event before initialization', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
})
})
describe('before_send', () => {

View File

@@ -126,7 +126,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',

View File

@@ -15,7 +15,9 @@
'flex flex-col contain-layout contain-style',
isRerouteNode
? 'h-(--node-height)'
: 'min-h-(--node-height) min-w-(--min-node-width)',
: loadVideoShrinkWrapBody
? 'h-auto min-h-0 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',
@@ -77,7 +79,9 @@
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
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',
'w-(--node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
shapeClass,
@@ -151,7 +155,8 @@
<div
:class="
cn(
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
'flex flex-col gap-1 bg-component-node-background pt-1',
loadVideoShrinkWrapBody ? 'flex-none' : 'flex-1',
bodyRoundingClass
)
"
@@ -182,7 +187,7 @@
v-if="!isTransparentHeaderless"
v-bind="badges"
:pricing="undefined"
class="mt-auto"
:class="loadVideoShrinkWrapBody ? undefined : 'mt-auto'"
/>
</div>
</template>
@@ -260,6 +265,7 @@ 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'
@@ -290,7 +296,10 @@ 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 { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import {
requestVueElementFreshMeasurement,
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'
@@ -473,7 +482,11 @@ function initSizeStyles() {
const fullHeight = height + LiteGraph.NODE_TITLE_HEIGHT
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
if (loadVideoShrinkWrapBody.value) {
el.style.removeProperty(`--node-height${suffix}`)
} else {
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
}
}
/**
@@ -494,9 +507,11 @@ 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`)
el.style.setProperty('--node-height', `${fullHeight}px`)
if (!loadVideoShrinkWrapBody.value) {
const fullHeight = newSize.height + LiteGraph.NODE_TITLE_HEIGHT
el.style.setProperty('--node-height', `${fullHeight}px`)
}
}
let unsubscribeLayoutChange: (() => void) | null = null
@@ -730,6 +745,28 @@ 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)

View File

@@ -8,6 +8,7 @@
: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'
@@ -15,7 +16,7 @@
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
flex: hasExpandingRows ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@@ -27,6 +28,9 @@
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
@@ -68,6 +72,9 @@
:class="
cn(
'col-span-2',
widget.type === 'videotrim' &&
loadVideoTrimFillsSpace &&
'h-full min-h-0',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@@ -128,8 +135,14 @@ onErrorCaptured((error) => {
return false
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
const {
canSelectInputs,
gridTemplateRows,
hasExpandingRows,
loadVideoTrimFillsSpace,
nodeType,
processedWidgets
} = useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -7,6 +7,7 @@ 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'
@@ -30,6 +31,7 @@ import {
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
createNodeExecutionId,
createNodeLocatorId
@@ -403,6 +405,8 @@ export function useProcessedWidgets(
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const nodeOutputStore = useNodeOutputStore()
const widgetValueStore = useWidgetValueStore()
const { isSelectInputsMode } = useAppMode()
const { handleNodeRightClick } = useNodeEventHandlers()
@@ -448,17 +452,54 @@ 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((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
visibleWidgets.value.map(widgetGridRow).join(' ')
)
const hasExpandingRows = computed(() =>
visibleWidgets.value.some(
(widget) => widgetGridRow(widget) !== 'min-content'
)
)
return {
canSelectInputs,
gridTemplateRows,
hasExpandingRows,
loadVideoTrimFillsSpace,
nodeType,
processedWidgets,
visibleWidgets

View File

@@ -87,6 +87,12 @@ 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

View File

@@ -3,6 +3,7 @@
v-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-id="nodeId"
:node-type="widget.nodeType ?? nodeType"
:asset-kind="assetKind"
:allow-upload="allowUpload"
@@ -35,6 +36,7 @@ import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { NodeId } from '@/types/nodeId'
import type { AssetKind } from '@/types/widgetTypes'
type StringControlWidget = SimplifiedControlWidget<string | undefined>
@@ -42,6 +44,7 @@ type StringControlWidget = SimplifiedControlWidget<string | undefined>
const props = defineProps<{
widget: SimplifiedWidget<string | undefined>
nodeType?: string
nodeId?: NodeId
}>()
const modelValue = defineModel<string | undefined>()

View File

@@ -79,6 +79,36 @@ 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(
() => {
@@ -144,6 +174,12 @@ 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(
@@ -257,4 +293,44 @@ 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()
})
})
})

View File

@@ -3,6 +3,7 @@ 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'
@@ -14,7 +15,9 @@ 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 {
@@ -24,6 +27,7 @@ import {
interface Props {
widget: SimplifiedWidget<string | undefined>
nodeId?: NodeId
nodeType?: string
assetKind?: AssetKind
allowUpload?: boolean
@@ -108,7 +112,9 @@ const mediaPlaceholder = computed(() => {
case 'image':
return t('widgets.uploadSelect.placeholderImage')
case 'video':
return t('widgets.uploadSelect.placeholderVideo')
return props.nodeType === 'LoadVideo'
? t('widgets.uploadSelect.browseAssetLibrary')
: t('widgets.uploadSelect.placeholderVideo')
case 'audio':
return t('widgets.uploadSelect.placeholderAudio')
case 'mesh':
@@ -124,6 +130,7 @@ const mediaPlaceholder = computed(() => {
const uploadable = computed(() => {
if (props.isAssetMode) return false
if (props.nodeType === 'LoadVideo') return false
return props.allowUpload === true
})
@@ -163,6 +170,38 @@ 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)
@@ -183,13 +222,14 @@ 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
:is-uploading="dropdownIsUploading"
v-bind="combinedProps"
:loading-more="outputMediaAssets.isLoadingMore.value"
class="w-full"

View File

@@ -0,0 +1,138 @@
<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>

View File

@@ -61,6 +61,7 @@ defineExpose({ focus })
>
<button
ref="buttonRef"
:disabled="disabled"
:class="
cn(
theButtonStyle,

View File

@@ -10,3 +10,12 @@ 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'
)

View File

@@ -437,8 +437,11 @@ describe('useComboWidget', () => {
]
)
expect(getInputWidgetDefault(mockNode)).toBe(scenario.assetHash)
expect(widget.value).toBe(scenario.assetHash)
const expectedDefault =
scenario.nodeClass === 'LoadVideo' ? '' : scenario.assetHash
expect(getInputWidgetDefault(mockNode)).toBe(expectedDefault)
expect(widget.value).toBe(expectedDefault)
}
)
@@ -468,8 +471,11 @@ describe('useComboWidget', () => {
]
)
expect(getInputWidgetDefault(mockNode)).toBe(scenario.assetHash)
expect(widget.value).toBe(scenario.assetHash)
const expectedDefault =
scenario.nodeClass === 'LoadVideo' ? '' : scenario.assetHash
expect(getInputWidgetDefault(mockNode)).toBe(expectedDefault)
expect(widget.value).toBe(expectedDefault)
}
)
@@ -740,6 +746,30 @@ 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

View File

@@ -25,7 +25,10 @@ import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
const getDefaultValue = (inputSpec: ComboInputSpec, nodeType?: string) => {
if (nodeType === 'LoadVideo' && inputSpec.name === 'file') {
return ''
}
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
@@ -150,6 +153,8 @@ function resolveCloudInputDefault(
nodeType: string | undefined,
specDefault: string | undefined
): string | undefined {
if (nodeType === 'LoadVideo') return undefined
const assets = getCloudInputAssets(nodeType)
if (specDefault != null) {
const matchingAsset =
@@ -238,7 +243,7 @@ const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
const defaultValue = getDefaultValue(inputSpec, node.comfyClass)
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {

View File

@@ -110,4 +110,27 @@ 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()
})
})

View File

@@ -113,9 +113,13 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
node.imgs = undefined
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
isAnimated
})
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 })
node.graph?.setDirtyCanvas(true)
}
@@ -123,7 +127,10 @@ 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(() => {
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
const raw = fileComboWidget.value
if (raw == null || raw === '' || raw === 'Loading...') return
nodeOutputStore.setNodeOutputs(node, String(raw), {
isAnimated
})
showPreview({ block: false })

View File

@@ -0,0 +1,100 @@
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()
})
})

View File

@@ -0,0 +1,126 @@
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
}

View File

@@ -69,6 +69,9 @@ 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')
)
@@ -226,6 +229,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
[
'videotrim',
{
component: WidgetVideoTrim,
aliases: ['VIDEOTRIM'],
essential: false
}
],
[
'boundingboxes',
{

View File

@@ -374,6 +374,9 @@ 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) =>
@@ -508,6 +511,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
nodeDefsByName,
nodeDefsByDisplayName,
allNodeDefsByName,
allNodeDefsByDisplayName,
showDeprecated,
showExperimental,
showDevOnly,

View File

@@ -0,0 +1,67 @@
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)
})
})

View File

@@ -0,0 +1,50 @@
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
}
}