Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/design-system/src/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,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);
Expand Down Expand Up @@ -532,6 +535,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);
Expand Down
349 changes: 349 additions & 0 deletions src/components/videoEdit/VideoFilmstripTrim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
/* eslint-disable testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, 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'

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
videoEdit: {
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')
})

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')
})

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)
const Host = defineComponent({
setup() {
return () =>
h(VideoFilmstripTrim, {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: playheadFrame.value,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
})
}
})
render(Host, {
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)]])
})
})
Loading
Loading