Skip to content

Commit feecf3b

Browse files
committed
feat(video): VIDEO_EDIT rich widget with trim and crop editors
1 parent 4f975be commit feecf3b

25 files changed

Lines changed: 2785 additions & 2 deletions

packages/design-system/src/css/style.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@
249249
--component-node-widget-promoted: var(--color-purple-700);
250250
--component-node-widget-advanced: var(--color-azure-400);
251251

252+
--video-trim-selection-background: var(--color-datatype-CLIP, #ffd500);
253+
--video-trim-playhead-background: #f0513b;
254+
252255
/* Default UI element color palette variables */
253256
--palette-contrast-mix-color: #fff;
254257
--palette-interface-panel-surface: var(--comfy-menu-bg);
@@ -532,6 +535,10 @@
532535
);
533536
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
534537
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
538+
--color-video-trim-selection-background: var(
539+
--video-trim-selection-background
540+
);
541+
--color-video-trim-playhead-background: var(--video-trim-playhead-background);
535542

536543
/* Semantic tokens */
537544
--color-base-foreground: var(--base-foreground);
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import userEvent from '@testing-library/user-event'
2+
import { render, screen } from '@testing-library/vue'
3+
import { describe, expect, it } from 'vitest'
4+
import { defineComponent, h } from 'vue'
5+
import { createI18n } from 'vue-i18n'
6+
7+
import type { ComponentProps } from 'vue-component-type-helpers'
8+
9+
import VideoEditPanel from './VideoEditPanel.vue'
10+
11+
const i18n = createI18n({
12+
legacy: false,
13+
locale: 'en',
14+
messages: {
15+
en: {
16+
videoEdit: {
17+
trimVideo: 'Trim Video',
18+
cropVideo: 'Crop Video',
19+
startFrame: 'Start Frame',
20+
endFrame: 'End Frame',
21+
duration: 'Duration',
22+
frames: 'Number of Frames',
23+
fileSize: 'File Size',
24+
resolution: '{width} × {height}',
25+
loadingVideo: 'Loading video preview',
26+
setStartFrame: 'Reset start frame',
27+
setEndFrame: 'Reset end frame',
28+
durationZero: '0s',
29+
durationSeconds: '{count}s',
30+
selectedOfTotal: '{selected} / {total}',
31+
fileSizeUnknown: '—',
32+
fileSizeBytes: '{count} B',
33+
fileSizeKilobytes: '{count} KB',
34+
fileSizeMegabytes: '{count} MB',
35+
noVideoSource: 'Select or connect a video to preview and edit'
36+
},
37+
imageCrop: {
38+
ratio: 'Ratio',
39+
custom: 'Custom',
40+
lockRatio: 'Lock ratio',
41+
unlockRatio: 'Unlock ratio'
42+
}
43+
}
44+
}
45+
})
46+
47+
function stub(testId: string) {
48+
return defineComponent({
49+
setup: () => () => h('div', { 'data-testid': testId })
50+
})
51+
}
52+
53+
const ToggleStub = defineComponent({
54+
props: {
55+
widget: { type: Object, required: true },
56+
modelValue: { type: Boolean, default: false }
57+
},
58+
emits: ['update:modelValue'],
59+
setup(props, { emit }) {
60+
return () =>
61+
h('button', {
62+
'data-testid': `toggle-${props.widget.name}`,
63+
onClick: () => emit('update:modelValue', !props.modelValue)
64+
})
65+
}
66+
})
67+
68+
type PanelProps = ComponentProps<typeof VideoEditPanel>
69+
70+
function renderPanel(props: Partial<PanelProps> = {}) {
71+
return render(VideoEditPanel, {
72+
props: {
73+
features: ['trim', 'crop'],
74+
videoUrl: '/api/view?filename=clip.mp4',
75+
thumbnails: ['data:image/jpeg;base64,one'],
76+
totalFrames: 100,
77+
duration: 10,
78+
fps: 10,
79+
fileSize: 2048,
80+
width: 1920,
81+
height: 1080,
82+
loading: false,
83+
...props
84+
} as PanelProps,
85+
global: {
86+
plugins: [i18n],
87+
directives: { tooltip: {} },
88+
stubs: {
89+
VideoFilmstripTrim: stub('stub-filmstrip'),
90+
VideoCropOverlay: stub('stub-crop-overlay'),
91+
WidgetInputNumberInput: stub('stub-number-input'),
92+
WidgetBoundingBox: stub('stub-bounding-box'),
93+
WidgetToggleSwitch: ToggleStub,
94+
Loader: stub('stub-loader'),
95+
Select: stub('stub-select'),
96+
SelectTrigger: stub('stub-select-trigger'),
97+
SelectValue: stub('stub-select-value'),
98+
SelectContent: stub('stub-select-content'),
99+
SelectItem: stub('stub-select-item'),
100+
Button: stub('stub-lock-button')
101+
}
102+
}
103+
})
104+
}
105+
106+
describe('VideoEditPanel', () => {
107+
it('shows an empty state without a video source', () => {
108+
renderPanel({ videoUrl: undefined })
109+
110+
expect(screen.getByTestId('video-edit-empty')).toBeTruthy()
111+
expect(screen.queryByTestId('video-preview')).toBeNull()
112+
expect(screen.queryByTestId('toggle-trim_enabled')).toBeNull()
113+
})
114+
115+
it('renders only the toggles of the enabled features', () => {
116+
renderPanel({ features: ['trim'] })
117+
118+
expect(screen.getByTestId('toggle-trim_enabled')).toBeTruthy()
119+
expect(screen.queryByTestId('toggle-crop_enabled')).toBeNull()
120+
})
121+
122+
it('keeps the filmstrip visible but collapses trim controls until enabled', async () => {
123+
renderPanel({ features: ['trim'] })
124+
125+
expect(screen.getByTestId('stub-filmstrip')).toBeTruthy()
126+
expect(screen.queryByTestId('stub-number-input')).toBeNull()
127+
128+
await userEvent.click(screen.getByTestId('toggle-trim_enabled'))
129+
130+
expect(screen.getAllByTestId('stub-number-input')).toHaveLength(2)
131+
})
132+
133+
it('expands the crop editor when the crop toggle is enabled', async () => {
134+
renderPanel({ features: ['crop'] })
135+
136+
expect(screen.queryByTestId('stub-crop-overlay')).toBeNull()
137+
expect(screen.queryByTestId('stub-bounding-box')).toBeNull()
138+
139+
await userEvent.click(screen.getByTestId('toggle-crop_enabled'))
140+
141+
expect(screen.getByTestId('stub-crop-overlay')).toBeTruthy()
142+
expect(screen.getByTestId('stub-bounding-box')).toBeTruthy()
143+
})
144+
145+
it('shows a loading overlay while the filmstrip loads', () => {
146+
renderPanel({ loading: true })
147+
148+
expect(screen.getByTestId('video-preview-loading')).toBeTruthy()
149+
})
150+
151+
it('shows selected/total metadata when trim is a feature', () => {
152+
renderPanel({
153+
features: ['trim'],
154+
startFrame: 0,
155+
endFrame: 99
156+
})
157+
158+
expect(screen.getByText('10s / 10s')).toBeTruthy()
159+
expect(screen.getByText('100 / 100')).toBeTruthy()
160+
expect(screen.getByText('2 KB')).toBeTruthy()
161+
})
162+
163+
it('shows plain totals when trim is not a feature', () => {
164+
renderPanel({ features: ['crop'] })
165+
166+
expect(screen.getByText('10s')).toBeTruthy()
167+
expect(screen.getByText('100')).toBeTruthy()
168+
})
169+
170+
it('renders the source resolution', () => {
171+
renderPanel()
172+
173+
expect(screen.getByText('1920 × 1080')).toBeTruthy()
174+
})
175+
})

0 commit comments

Comments
 (0)