Compare commits

...

12 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
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
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
50 changed files with 4285 additions and 33 deletions

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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
}
}