Compare commits

...

7 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
384a96a047 fix: e2e test 2026-06-26 13:18:44 -07:00
Nathaniel Parson Koroso
f9519c43ba refactor: replace video preview JS resize-floor with static min-height 2026-06-26 10:46:19 -07:00
Nathaniel Parson Koroso
24b80b719a fix: resolve coderabbit nit 2026-06-26 10:14:15 -07:00
Nathaniel Parson Koroso
c9e10717c7 test: cover extreme tall aspect-ratio resize floor
Strengthen video preview shape coverage

Assert that each video preview fixture exercises its intended aspect shape, so the E2E genuinely covers landscape, square, and portrait thumbnails. Also verify vertical node resizing keeps the preview width stable while preserving centering.

perf: read video preview resize floor once per resize, not per frame
2026-06-26 10:04:12 -07:00
Nathaniel Parson Koroso
92b8a86da0 refactor: remove inert centering classes from video preview wrapper 2026-06-25 20:08:26 -07:00
Nathaniel Parson Koroso
d097a6a086 fix: use fixed default size so node doesn't resize (video letterboxes)
Prevents auto-grow/shrink node
2026-06-25 19:28:20 -07:00
Nathaniel Parson Koroso
3ac32be85d fix: center video previews while resizing nodes
Fix FE-1092 by making the Vue video preview slot keep an aspect-derived minimum frame height while the node remains independently resizable on both axes. The video fills the existing preview slot with object-contain so landscape, square, and portrait assets stay centered instead of pinning to the top or bottom during resize.

Add focused unit coverage for the fallback height, 100px floor, and portrait aspect-derived height. Add browser coverage using committed tiny WebM fixtures for landscape, square, portrait, horizontal resize, and vertical resize behavior.
2026-06-25 18:21:21 -07:00
6 changed files with 367 additions and 61 deletions

View File

@@ -1,77 +1,379 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
const file2 = 'video-preview-wide.webm' as const
const file3 = 'video-preview-square.webm' as const
const file4 = 'video-preview-portrait.webm' as const
const MIN_PREVIEW_FRAME_HEIGHT = 100
const CENTER_TOLERANCE_PX = 1
const videoShapeFixtures = [
[file2, 'landscape'],
[file3, 'square'],
[file4, 'portrait']
] as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
interface VideoPreviewLayout {
objectFit: string
objectPosition: string
wrapperHeight: number
wrapperWidth: number
wrapperX: number
wrapperY: number
videoBoxHeight: number
videoBoxWidth: number
videoIntrinsicHeight: number
videoIntrinsicWidth: number
videoX: number
videoY: number
}
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
async function readVideoPreviewLayout(
preview: Locator
): Promise<VideoPreviewLayout | null> {
return await preview.evaluate((previewElement) => {
const video = previewElement.querySelector('video')
const wrapper = video?.parentElement
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
const wrapperRect = wrapper.getBoundingClientRect()
const videoRect = video.getBoundingClientRect()
return {
objectFit: getComputedStyle(video).objectFit,
objectPosition: getComputedStyle(video).objectPosition,
wrapperHeight: wrapperRect.height,
wrapperWidth: wrapperRect.width,
wrapperX: wrapperRect.x,
wrapperY: wrapperRect.y,
videoBoxHeight: videoRect.height,
videoBoxWidth: videoRect.width,
videoIntrinsicHeight: video.videoHeight,
videoIntrinsicWidth: video.videoWidth,
videoX: videoRect.x,
videoY: videoRect.y
}
})
}
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
})
async function requireBoundingBox(locator: Locator, subject: string) {
const box = await locator.boundingBox()
if (!box) throw new Error(`${subject} should have a bounding box`)
await test.step('Update displayed video', async () => {
const initialSrc = await loadVideo.videoSrc()
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
await expect(loadVideoNode).toContainText(file2)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
})
return box
}
await test.step('Display multiple videmus', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
async function expectNodeBoxUnchanged(
locator: Locator,
before: { height: number; width: number },
subject: string
) {
const after = await requireBoundingBox(locator, subject)
expect(
Math.abs(after.width - before.width),
`${subject} should not change node width`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(after.height - before.height),
`${subject} should not change node height`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
}
//forcibly display multiple video files at once
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
subfolder: ''
}))
function objectPositionFraction(value: string) {
if (value.endsWith('%')) return Number.parseFloat(value) / 100
switch (value) {
case 'left':
case 'top':
return 0
case 'center':
return 0.5
case 'right':
case 'bottom':
return 1
default:
throw new Error(`Unsupported object-position value: ${value}`)
}
}
function objectPositionFractions(objectPosition: string) {
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
return {
x: objectPositionFraction(x),
y: objectPositionFraction(y)
}
}
function getPaintedVideoRect({
objectPosition,
videoBoxHeight,
videoBoxWidth,
videoIntrinsicHeight,
videoIntrinsicWidth,
videoX,
videoY
}: VideoPreviewLayout) {
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
const boxAspectRatio = videoBoxWidth / videoBoxHeight
const paintedWidth =
videoAspectRatio > boxAspectRatio
? videoBoxWidth
: videoBoxHeight * videoAspectRatio
const paintedHeight =
videoAspectRatio > boxAspectRatio
? videoBoxWidth / videoAspectRatio
: videoBoxHeight
const position = objectPositionFractions(objectPosition)
return {
height: paintedHeight,
width: paintedWidth,
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
y: videoY + (videoBoxHeight - paintedHeight) * position.y
}
}
function expectAspectRatioMatchesShape(
aspectRatio: number,
shape: ThumbnailShape
) {
if (shape === 'landscape') {
expect(
aspectRatio,
'landscape fixture should be wider than tall'
).toBeGreaterThan(1)
return
}
if (shape === 'portrait') {
expect(
aspectRatio,
'portrait fixture should be taller than wide'
).toBeLessThan(1)
return
}
expect(
Math.abs(aspectRatio - 1),
'square fixture should have matching width and height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
}
async function expectCenteredVideoPreview(preview: Locator) {
await expect
.poll(async () => {
const layout = await readVideoPreviewLayout(preview)
return layout?.videoIntrinsicWidth ?? 0
})
.toBeGreaterThan(0)
const layout = await readVideoPreviewLayout(preview)
if (!layout) throw new Error('Video preview should render a video element')
expect(
layout.wrapperHeight,
'video preview should keep a usable minimum frame height'
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeGreaterThan(0)
expect(layout.videoBoxHeight).toBeGreaterThan(0)
expect(layout.objectFit).toBe('contain')
const objectPosition = objectPositionFractions(layout.objectPosition)
expect(objectPosition.x).toBe(0.5)
expect(objectPosition.y).toBe(0.5)
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
const paintedVideo = getPaintedVideoRect(layout)
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
expect(
Math.abs(paintedVideoCenterX - wrapperCenterX),
'painted video should be horizontally centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(paintedVideoCenterY - wrapperCenterY),
'painted video should be vertically centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
expect(paintedVideo.width).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(paintedVideo.height).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
return layout
}
test.describe(
'VideoPreview',
{ tag: ['@vue-nodes', '@node', '@widget'] },
() => {
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
let loadVideoFixture: VueNodeFixture
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
loadVideoFixture =
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
})
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(
assetPath(`workflowInMedia/${file1}`)
)
},
[file1, file2]
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
})
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
await test.step('Update displayed video across thumbnail shapes', async () => {
for (const [filename, shape] of videoShapeFixtures) {
const initialSrc = await loadVideo.videoSrc()
const nodeBoxBeforeLoad = await requireBoundingBox(
loadVideoNode,
`Load Video node before loading ${filename}`
)
await loadVideo.upload.setInputFiles(
assetPath(`workflowInMedia/${filename}`)
)
comfyFiles.deleteAfterTest({
filename,
type: 'input'
})
await expect(loadVideoNode).toContainText(filename)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})
const layout = await expectCenteredVideoPreview(loadVideo.preview)
await expectNodeBoxUnchanged(
loadVideoNode,
nodeBoxBeforeLoad,
`Load Video node after loading ${filename}`
)
const updatedVideoAspectRatio =
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
}
})
await test.step('Keep video centered after horizontal resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before horizontal resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
)
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollWidth)
.toBeGreaterThan(nodeBox.width + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperWidth - initialLayout.wrapperWidth,
'video preview space should grow with a wider node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
'horizontal resize should not change the preview space height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await test.step('Keep video centered after vertical resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before vertical resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
)
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollHeight)
.toBeGreaterThan(nodeBox.height + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperHeight - initialLayout.wrapperHeight,
'video preview space should grow with a taller node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
'vertical resize should not change the preview space width'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await test.step('Display multiple videos', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
try {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
subfolder: ''
}))
)
},
[file1, file2]
)
} finally {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.nextFrame()
}
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})
}
)

View File

@@ -1,13 +1,13 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
class="video-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
ref="videoWrapperEl"
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
class="relative flex min-h-0 w-full flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@@ -45,7 +45,12 @@
<video
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
:class="
cn(
'absolute inset-0 block size-full object-contain',
showLoader && 'invisible'
)
"
preload="metadata"
controls
loop

View File

@@ -79,7 +79,6 @@ function createMockNodeElement(
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
// When --node-height is '0px', return the content-driven minimum height
const nodeHeight = element.style.getPropertyValue('--node-height')
const h = nodeHeight === '0px' ? minContentHeight : height
return {