Skip to content

Commit b366170

Browse files
committed
feat(video): VIDEO_EDIT rich widget with trim and crop editors
1 parent cb5dc36 commit b366170

39 files changed

Lines changed: 4635 additions & 1 deletion

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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/* eslint-disable testing-library/prefer-user-event -- crop dragging needs low-level pointer events */
2+
import { fireEvent, render, screen } from '@testing-library/vue'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { ref } from 'vue'
5+
import { createI18n } from 'vue-i18n'
6+
7+
import type { Bounds } from '@/renderer/core/layout/types'
8+
9+
import VideoCropOverlay from './VideoCropOverlay.vue'
10+
11+
const i18n = createI18n({
12+
legacy: false,
13+
locale: 'en',
14+
messages: {
15+
en: {
16+
videoEdit: {
17+
adjustCrop: 'Adjust crop region'
18+
}
19+
}
20+
}
21+
})
22+
23+
function renderOverlay({
24+
bounds = { x: 100, y: 100, width: 200, height: 200 },
25+
disabled = false,
26+
lockedRatio = null as number | null
27+
} = {}) {
28+
const model = ref<Bounds>(bounds)
29+
render(VideoCropOverlay, {
30+
props: {
31+
modelValue: model.value,
32+
sourceWidth: 1000,
33+
sourceHeight: 1000,
34+
disabled,
35+
lockedRatio,
36+
'onUpdate:modelValue': (value: Bounds) => {
37+
model.value = value
38+
}
39+
},
40+
global: {
41+
plugins: [i18n]
42+
}
43+
})
44+
45+
const root = screen.getByTestId('video-crop-overlay')
46+
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
47+
left: 0,
48+
top: 0,
49+
width: 100,
50+
height: 100,
51+
right: 100,
52+
bottom: 100,
53+
x: 0,
54+
y: 0,
55+
toJSON: () => ({})
56+
})
57+
58+
return { model }
59+
}
60+
61+
describe('VideoCropOverlay', () => {
62+
it('positions the crop box by source-relative percentages', () => {
63+
renderOverlay({ bounds: { x: 100, y: 200, width: 500, height: 250 } })
64+
65+
const box = screen.getByLabelText('Adjust crop region')
66+
expect(box.style.left).toBe('10%')
67+
expect(box.style.top).toBe('20%')
68+
expect(box.style.width).toBe('50%')
69+
expect(box.style.height).toBe('25%')
70+
})
71+
72+
it('renders eight resize handles', () => {
73+
renderOverlay()
74+
75+
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
76+
expect(screen.getByTestId(`crop-handle-${dir}`)).toBeTruthy()
77+
}
78+
})
79+
80+
it('moves the crop box by dragging it', async () => {
81+
const { model } = renderOverlay()
82+
83+
const box = screen.getByLabelText('Adjust crop region')
84+
box.setPointerCapture = vi.fn()
85+
86+
await fireEvent.pointerDown(box, {
87+
clientX: 0,
88+
clientY: 0,
89+
button: 0,
90+
pointerId: 1
91+
})
92+
await fireEvent.pointerMove(box, {
93+
clientX: 5,
94+
clientY: 7,
95+
pointerId: 1
96+
})
97+
await fireEvent.pointerUp(box, { pointerId: 1 })
98+
99+
expect(model.value).toEqual({ x: 150, y: 170, width: 200, height: 200 })
100+
})
101+
102+
it('resizes the crop box from a corner handle', async () => {
103+
const { model } = renderOverlay()
104+
105+
const handle = screen.getByTestId('crop-handle-se')
106+
handle.setPointerCapture = vi.fn()
107+
108+
await fireEvent.pointerDown(handle, {
109+
clientX: 0,
110+
clientY: 0,
111+
button: 0,
112+
pointerId: 1
113+
})
114+
await fireEvent.pointerMove(handle, {
115+
clientX: 10,
116+
clientY: 5,
117+
pointerId: 1
118+
})
119+
await fireEvent.pointerUp(handle, { pointerId: 1 })
120+
121+
expect(model.value).toEqual({ x: 100, y: 100, width: 300, height: 250 })
122+
})
123+
124+
it('does not react to drags while disabled', async () => {
125+
const { model } = renderOverlay({ disabled: true })
126+
127+
const box = screen.getByLabelText('Adjust crop region')
128+
box.setPointerCapture = vi.fn()
129+
130+
await fireEvent.pointerDown(box, {
131+
clientX: 0,
132+
clientY: 0,
133+
button: 0,
134+
pointerId: 1
135+
})
136+
await fireEvent.pointerMove(box, { clientX: 5, clientY: 5, pointerId: 1 })
137+
138+
expect(model.value).toEqual({ x: 100, y: 100, width: 200, height: 200 })
139+
})
140+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<template>
2+
<div
3+
ref="rootEl"
4+
class="pointer-events-none absolute inset-0 overflow-hidden"
5+
data-testid="video-crop-overlay"
6+
>
7+
<div
8+
:class="
9+
cn(
10+
'pointer-events-auto absolute cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
11+
disabled && 'pointer-events-none opacity-60'
12+
)
13+
"
14+
:style="cropBoxStyle"
15+
data-testid="crop-box"
16+
:aria-label="$t('videoEdit.adjustCrop')"
17+
@pointerdown.stop="startDrag('move', $event)"
18+
/>
19+
<div
20+
v-for="handle in HANDLES"
21+
:key="handle.dir"
22+
:class="
23+
cn(
24+
'pointer-events-auto absolute size-2.5 -translate-1/2 border border-black/40 bg-white',
25+
handle.cursor,
26+
disabled && 'pointer-events-none opacity-60'
27+
)
28+
"
29+
:style="handleStyle(handle.dir)"
30+
:data-testid="`crop-handle-${handle.dir}`"
31+
@pointerdown.stop="startDrag(handle.dir, $event)"
32+
/>
33+
</div>
34+
</template>
35+
36+
<script setup lang="ts">
37+
import { computed, toRef, useTemplateRef } from 'vue'
38+
39+
import { useCropBoxEditor } from '@/composables/video/useCropBoxEditor'
40+
import type { CropResizeDir } from '@/composables/video/useCropBoxEditor'
41+
import type { Bounds } from '@/renderer/core/layout/types'
42+
import { cn } from '@comfyorg/tailwind-utils'
43+
44+
const HANDLES: Array<{ dir: CropResizeDir; cursor: string }> = [
45+
{ dir: 'nw', cursor: 'cursor-nwse-resize' },
46+
{ dir: 'n', cursor: 'cursor-ns-resize' },
47+
{ dir: 'ne', cursor: 'cursor-nesw-resize' },
48+
{ dir: 'e', cursor: 'cursor-ew-resize' },
49+
{ dir: 'se', cursor: 'cursor-nwse-resize' },
50+
{ dir: 's', cursor: 'cursor-ns-resize' },
51+
{ dir: 'sw', cursor: 'cursor-nesw-resize' },
52+
{ dir: 'w', cursor: 'cursor-ew-resize' }
53+
]
54+
55+
const {
56+
sourceWidth,
57+
sourceHeight,
58+
lockedRatio = null,
59+
disabled = false
60+
} = defineProps<{
61+
sourceWidth: number
62+
sourceHeight: number
63+
lockedRatio?: number | null
64+
disabled?: boolean
65+
}>()
66+
67+
const bounds = defineModel<Bounds>({ required: true })
68+
69+
const rootEl = useTemplateRef<HTMLDivElement>('rootEl')
70+
71+
const { startDrag } = useCropBoxEditor(bounds, {
72+
rootEl,
73+
sourceWidth: toRef(() => sourceWidth),
74+
sourceHeight: toRef(() => sourceHeight),
75+
isDisabled: () => disabled,
76+
lockedRatio: toRef(() => lockedRatio)
77+
})
78+
79+
function pct(value: number, total: number) {
80+
return total > 0 ? `${(value / total) * 100}%` : '0%'
81+
}
82+
83+
const cropBoxStyle = computed(() => ({
84+
left: pct(bounds.value.x, sourceWidth),
85+
top: pct(bounds.value.y, sourceHeight),
86+
width: pct(bounds.value.width, sourceWidth),
87+
height: pct(bounds.value.height, sourceHeight)
88+
}))
89+
90+
function handleStyle(dir: CropResizeDir) {
91+
const { x, y, width, height } = bounds.value
92+
const cx = dir.includes('w')
93+
? x
94+
: dir.includes('e')
95+
? x + width
96+
: x + width / 2
97+
const cy = dir.includes('n')
98+
? y
99+
: dir.includes('s')
100+
? y + height
101+
: y + height / 2
102+
return { left: pct(cx, sourceWidth), top: pct(cy, sourceHeight) }
103+
}
104+
</script>

0 commit comments

Comments
 (0)