Download frontend/src/tests/ProjectVideoCombineWidget.test.tsx from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 39.6 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/tests/ProjectVideoCombineWidget.test.tsx
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/frontend/src/tests/ProjectVideoCombineWidget.test.tsx
-
curl -L -o ProjectVideoCombineWidget.test.tsx https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/tests/ProjectVideoCombineWidget.test.tsx
39.6 kB
| import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' | |
| import { useState } from 'react' | |
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| import { ProjectVideoCombineWidget } from '@/components/widgets/ProjectVideoCombineWidget' | |
| import type { ReactWidgetProps } from '@/lib/create-react-widget' | |
| import type { ProjectData } from '@/types/project' | |
| vi.mock('@/hooks/use-canvas-scale', () => ({ useCanvasScale: () => 1 })) | |
| vi.mock('@/hooks/use-element-width', () => ({ useElementWidth: () => 480 })) | |
| vi.mock('@/components/widgets/mediaSelector/MediaSelector', () => ({ MediaSelector: () => null })) | |
| const projectData: ProjectData = { | |
| project_name: 'demo', | |
| width: 1280, | |
| height: 720, | |
| frame_rate: 24, | |
| auto_combine: true, | |
| clips: [{ | |
| id: 'segment-0', | |
| index: 0, | |
| file_path: 'easy_media/projects/demo/video_0_1.mp4', | |
| file_name: 'video_0_1.mp4', | |
| media_revision: '1000000001', | |
| source_start_frame: 0, | |
| source_end_frame: 120, | |
| source_frame_count: 120, | |
| continuity_mode: 'shot', | |
| enabled: true, | |
| }], | |
| } | |
| function widgetProps(overrides: Partial<ReactWidgetProps<ProjectData>> = {}) { | |
| const toast = { add: vi.fn() } | |
| const dialog = { confirm: vi.fn().mockResolvedValue(true) } | |
| const workflow = { | |
| activeWorkflow: { key: 'workflow-a' }, | |
| $subscribe: vi.fn().mockReturnValue(vi.fn()), | |
| } | |
| const api = { | |
| fetchApi: vi.fn(), | |
| getQueue: vi.fn().mockResolvedValue({ Running: [], Pending: [] }), | |
| queuePrompt: vi.fn().mockResolvedValue(undefined), | |
| addEventListener: vi.fn(), | |
| removeEventListener: vi.fn(), | |
| addCustomEventListener: vi.fn(), | |
| removeCustomEventListener: vi.fn(), | |
| } | |
| const node = { | |
| id: 8, | |
| graph: { links: { 7: { origin_id: 3, target_id: 8 }, 9: { origin_id: 8, target_id: 10 } } }, | |
| } | |
| const graphToPrompt = vi.fn().mockResolvedValue({ | |
| workflow: { nodes: [] }, | |
| output: { | |
| '3': { inputs: { project_name: 'demo' }, class_type: 'easy multitrackProject' }, | |
| '8': { | |
| inputs: { project_name: ['3', 0], project_data: JSON.stringify(projectData) }, | |
| class_type: 'easy multitrackProjectVideoCombine', | |
| }, | |
| '10': { | |
| inputs: { video: ['8', 0], filename_prefix: ['8', 1] }, | |
| class_type: 'easy saveVideo', | |
| }, | |
| }, | |
| }) | |
| const props = { | |
| value: projectData, | |
| onChange: vi.fn(), | |
| inputName: 'project_data', | |
| node, | |
| widget: {}, | |
| app: { | |
| api, | |
| graphToPrompt, | |
| ui: { settings: { settingsValues: { 'Comfy.Locale': 'en' } } }, | |
| extensionManager: { toast, dialog, workflow }, | |
| }, | |
| ...overrides, | |
| } as unknown as ReactWidgetProps<ProjectData> | |
| return { props, api, toast, dialog, workflow, graphToPrompt } | |
| } | |
| beforeEach(() => { | |
| Object.defineProperty(document, 'visibilityState', { | |
| configurable: true, | |
| value: 'visible', | |
| }) | |
| vi.stubGlobal('ResizeObserver', class { | |
| observe() {} | |
| unobserve() {} | |
| disconnect() {} | |
| }) | |
| vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() | |
| vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}) | |
| }) | |
| describe('ProjectVideoCombineWidget', () => { | |
| function renderComparison() { | |
| let animationFrame: FrameRequestCallback | undefined | |
| vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { | |
| animationFrame = callback | |
| return 1 | |
| })) | |
| vi.stubGlobal('cancelAnimationFrame', vi.fn()) | |
| const clip = { | |
| ...projectData.clips[0], | |
| source_start_frame: 24, | |
| video_files: [projectData.clips[0], { | |
| file_path: 'easy_media/projects/demo/video_0_2.mp4', | |
| file_name: 'video_0_2.mp4', | |
| source_frame_count: 72, | |
| }], | |
| } | |
| const second = { | |
| ...projectData.clips[0], id: 'segment-1', index: 1, | |
| file_path: 'easy_media/projects/demo/video_1_1.mp4', file_name: 'video_1_1.mp4', | |
| } | |
| const { props } = widgetProps({ value: { ...projectData, clips: [clip, second] } }) | |
| const result = render(<ProjectVideoCombineWidget {...props} />) | |
| const trigger = screen.getByRole('button', { name: 'Select up to two videos for segment 1' }) | |
| fireEvent.click(trigger) | |
| fireEvent.click(screen.getByRole('checkbox', { name: 'video_0_2.mp4' })) | |
| fireEvent.click(trigger) | |
| const child = within(result.container.querySelector('[data-compare-video-playback]') as HTMLElement) | |
| const mainPlay = screen.getAllByRole('button', { name: 'Play' }).find((button) => !button.closest('[data-compare-video-playback]')) as HTMLButtonElement | |
| return { ...result, child, mainPlay, tick: (time: number) => act(() => animationFrame?.(time)) } | |
| } | |
| it('plays comparisons from the project trim and continues into the next segment', () => { | |
| const { container, child, mainPlay, tick } = renderComparison() | |
| const videos = [...container.querySelectorAll('video')] | |
| expect(mainPlay.disabled).toBe(false) | |
| expect(videos[0].currentTime).toBe(1) | |
| expect(videos[1].currentTime).toBe(1) | |
| fireEvent.click(mainPlay) | |
| const started = performance.now() | |
| expect(child.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| expect(videos.every((video) => !video.loop)).toBe(true) | |
| tick(started + 2000) | |
| expect(videos[0].currentTime).toBeCloseTo(3, 0) | |
| expect(videos[1].currentTime).toBeCloseTo(3, 0) | |
| const pause = vi.spyOn(videos[0], 'pause') | |
| tick(started + 4200) | |
| expect(pause).toHaveBeenCalled() | |
| expect(container.querySelector('[data-compare-video-playback]')).toBeNull() | |
| const next = container.querySelector('video')! | |
| expect(next.src).toContain('video_1_1.mp4') | |
| expect(vi.mocked(next.play).mock.contexts).toContain(next) | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| }) | |
| it('keeps local comparison playback independent while the project is paused', () => { | |
| const { container, child, mainPlay } = renderComparison() | |
| const videos = [...container.querySelectorAll('video')] | |
| fireEvent.click(child.getByRole('button', { name: 'Play' })) | |
| expect(mainPlay.getAttribute('aria-label')).toBe('Play') | |
| videos[1].currentTime = 2 | |
| fireEvent.timeUpdate(videos[1]) | |
| expect(videos[0].currentTime).toBe(2) | |
| expect(container.querySelector('.text-gradient')?.textContent).toBe('00:00:00') | |
| fireEvent.ended(videos[1]) | |
| expect(container.querySelector('[data-compare-video-playback]')).not.toBeNull() | |
| expect(mainPlay.getAttribute('aria-label')).toBe('Play') | |
| }) | |
| it('does not restart a shorter comparison video or rewind the parent timeline when it ends', () => { | |
| const { container, mainPlay, tick } = renderComparison() | |
| const videos = [...container.querySelectorAll('video')] | |
| Object.defineProperty(videos[1], 'duration', { configurable: true, value: 3 }) | |
| fireEvent.click(mainPlay) | |
| const started = performance.now() | |
| tick(started + 2500) | |
| expect(videos[1].currentTime).toBe(3) | |
| const play = vi.spyOn(videos[1], 'play') | |
| play.mockClear() | |
| fireEvent.ended(videos[1]) | |
| expect(play).not.toHaveBeenCalled() | |
| expect(videos[0].currentTime).toBeGreaterThan(3) | |
| expect(mainPlay.getAttribute('aria-label')).toBe('Pause') | |
| fireEvent.keyDown(screen.getByRole('slider', { name: 'Video time' }), { key: 'Home' }) | |
| expect(videos[1].currentTime).toBe(1) | |
| expect(play).toHaveBeenCalled() | |
| tick(started + 4200) | |
| expect(container.querySelector('video')?.src).toContain('video_1_1.mp4') | |
| }) | |
| it('pauses both clocks from the child during parent playback, then allows local playback', () => { | |
| const { child, mainPlay } = renderComparison() | |
| fireEvent.click(mainPlay) | |
| fireEvent.click(child.getByRole('button', { name: 'Pause' })) | |
| expect(mainPlay.getAttribute('aria-label')).toBe('Play') | |
| fireEvent.click(child.getByRole('button', { name: 'Play' })) | |
| expect(mainPlay.getAttribute('aria-label')).toBe('Play') | |
| fireEvent.click(mainPlay) | |
| fireEvent.click(mainPlay) | |
| expect(child.getByRole('button', { name: 'Play' })).not.toBeNull() | |
| }) | |
| it('refreshes the selected project and clears the stale preview first', async () => { | |
| const { props, api } = widgetProps({ value: { ...projectData, clips: [] } }) | |
| api.fetchApi.mockResolvedValue({ ok: true, json: async () => projectData }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| await waitFor(() => expect(props.onChange).toHaveBeenLastCalledWith(projectData)) | |
| expect(api.fetchApi).toHaveBeenCalledWith('/easy-media/project?project_name=demo') | |
| }) | |
| it('shows an externally assigned project name before the project list is loaded', () => { | |
| const { props } = widgetProps({ value: { ...projectData, project_name: 'assigned-project' } }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| expect(screen.getByRole('combobox', { name: 'Select project' }).textContent).toContain('assigned-project') | |
| }) | |
| it('treats a missing default project manifest as an empty project without an error toast', async () => { | |
| const defaultData = { ...projectData, project_name: 'default' } | |
| const { props, api, toast } = widgetProps({ value: defaultData }) | |
| api.fetchApi.mockResolvedValue({ | |
| ok: false, | |
| status: 404, | |
| json: async () => ({ error: 'H3 project manifest was not found' }), | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Refresh project' })) | |
| await waitFor(() => expect(api.fetchApi).toHaveBeenCalledWith( | |
| '/easy-media/project?project_name=default', | |
| )) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalledWith({ | |
| project_name: 'default', | |
| width: 0, | |
| height: 0, | |
| frame_rate: 24, | |
| clips: [], | |
| auto_combine: true, | |
| })) | |
| expect(toast.add).not.toHaveBeenCalled() | |
| }) | |
| it('still reports a missing manifest for a named project', async () => { | |
| const { props, api, toast } = widgetProps() | |
| api.fetchApi.mockResolvedValue({ | |
| ok: false, | |
| status: 404, | |
| json: async () => ({ error: 'H3 project manifest was not found' }), | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Refresh project' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', | |
| summary: 'Project refresh failed', | |
| }))) | |
| }) | |
| it('confirms and deletes the selected project through the ComfyUI API', async () => { | |
| const { props, api, toast, dialog } = widgetProps() | |
| api.fetchApi.mockResolvedValue({ | |
| ok: true, | |
| json: async () => ({ project_name: 'demo', deleted: true }), | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete project' })) | |
| await waitFor(() => expect(dialog.confirm).toHaveBeenCalledWith({ | |
| title: 'Delete project?', | |
| message: expect.stringContaining('demo'), | |
| })) | |
| await waitFor(() => expect(api.fetchApi).toHaveBeenCalledWith( | |
| '/easy-media/project?project_name=demo', | |
| { method: 'DELETE' }, | |
| )) | |
| expect(props.onChange).toHaveBeenCalledWith({ | |
| project_name: 'default', | |
| width: 0, | |
| height: 0, | |
| frame_rate: 24, | |
| clips: [], | |
| auto_combine: true, | |
| }) | |
| expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'success', | |
| summary: 'Project deleted', | |
| })) | |
| }) | |
| it('does not call the delete API when confirmation is declined', async () => { | |
| const { props, api, dialog } = widgetProps() | |
| dialog.confirm.mockResolvedValue(false) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete project' })) | |
| await waitFor(() => expect(dialog.confirm).toHaveBeenCalledTimes(1)) | |
| expect(api.fetchApi).not.toHaveBeenCalled() | |
| }) | |
| it('explains that deleting default clears its files but keeps the folder', async () => { | |
| const defaultData = { ...projectData, project_name: 'default' } | |
| const { props, api, dialog } = widgetProps({ value: defaultData }) | |
| api.fetchApi.mockResolvedValue({ | |
| ok: true, | |
| json: async () => ({ project_name: 'default', deleted: true }), | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete project' })) | |
| await waitFor(() => expect(dialog.confirm).toHaveBeenCalledWith(expect.objectContaining({ | |
| message: expect.stringContaining('default folder will be kept'), | |
| }))) | |
| await waitFor(() => expect(api.fetchApi).toHaveBeenCalledWith( | |
| '/easy-media/project?project_name=default', | |
| { method: 'DELETE' }, | |
| )) | |
| }) | |
| it('keeps the current preview during same-project refresh and updates its revision', async () => { | |
| let successHandler: (() => void) | undefined | |
| const refreshedData: ProjectData = { | |
| ...projectData, | |
| clips: [{ ...projectData.clips[0], media_revision: '1000000002' }], | |
| } | |
| const { props, api } = widgetProps() | |
| api.addEventListener.mockImplementation((name: string, handler: () => void) => { | |
| if (name === 'execution_success') successHandler = handler | |
| }) | |
| api.fetchApi.mockResolvedValue({ ok: true, json: async () => refreshedData }) | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| expect(container.querySelector('video')?.getAttribute('src')).toContain('v=1000000001') | |
| await act(async () => successHandler?.()) | |
| expect(props.onChange).not.toHaveBeenCalledWith(expect.objectContaining({ clips: [] })) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalledWith(refreshedData)) | |
| }) | |
| it('switches to the project from a sampling refresh event', async () => { | |
| let refreshHandler: ((event: CustomEvent<unknown>) => void) | undefined | |
| const refreshedData: ProjectData = { | |
| ...projectData, | |
| project_name: 'next-project', | |
| clips: [{ ...projectData.clips[0], media_revision: '1000000002' }], | |
| } | |
| const { props, api } = widgetProps() | |
| api.addCustomEventListener.mockImplementation((name: string, handler: () => void) => { | |
| if (name === 'easy_multitrack_project_refresh') refreshHandler = handler | |
| }) | |
| api.fetchApi.mockResolvedValue({ ok: true, json: async () => refreshedData }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| await act(async () => refreshHandler?.(new CustomEvent('easy_multitrack_project_refresh', { | |
| detail: { project_name: 'next-project', phase: 'before', segment_index: 0 }, | |
| }))) | |
| expect(api.fetchApi).toHaveBeenCalledWith('/easy-media/project?project_name=next-project') | |
| expect(props.onChange).toHaveBeenCalledWith(expect.objectContaining({ | |
| project_name: 'next-project', | |
| clips: [], | |
| })) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalledWith(refreshedData)) | |
| }) | |
| it('does not queue manual combine while ComfyUI has queued work', async () => { | |
| const { props, api, toast } = widgetProps() | |
| api.getQueue.mockResolvedValue({ Running: [{ id: 'running' }], Pending: [] }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Combine' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', | |
| summary: 'Project combine failed', | |
| }))) | |
| expect(api.queuePrompt).not.toHaveBeenCalled() | |
| }) | |
| it('queues only the combine node and downstream save nodes', async () => { | |
| const { props, api, graphToPrompt } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Combine' })) | |
| await waitFor(() => expect(api.queuePrompt).toHaveBeenCalledTimes(1)) | |
| expect(graphToPrompt).toHaveBeenCalledTimes(1) | |
| const queued = api.queuePrompt.mock.calls[0][1] | |
| expect(Object.keys(queued.output)).toEqual(['8', '10']) | |
| expect(JSON.parse(queued.output['8'].inputs.project_data)).toEqual(projectData) | |
| expect(queued.output['10'].inputs).toEqual({ video: ['8', 0], filename_prefix: ['8', 1] }) | |
| }) | |
| it('queues downstream nodes after a reloaded graph restores links as a Map', async () => { | |
| const { props, api } = widgetProps() | |
| const reloadedNode = { | |
| id: 8, | |
| graph: { | |
| links: new Map([ | |
| [7, { origin_id: 3, target_id: 8 }], | |
| [9, { origin_id: 8, target_id: 10 }], | |
| ]), | |
| }, | |
| } | |
| render(<ProjectVideoCombineWidget {...props} node={reloadedNode} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Combine' })) | |
| await waitFor(() => expect(api.queuePrompt).toHaveBeenCalledTimes(1)) | |
| expect(Object.keys(api.queuePrompt.mock.calls[0][1].output)).toEqual(['8', '10']) | |
| }) | |
| it('removes stale external input links omitted from the API prompt after reload', async () => { | |
| const { props, api, graphToPrompt } = widgetProps() | |
| const reloadedNode = { | |
| id: 8, | |
| graph: { | |
| links: new Map([ | |
| [7, { origin_id: 3, target_id: 8 }], | |
| [9, { origin_id: 8, target_id: 10 }], | |
| [11, { origin_id: 11, target_id: 10 }], | |
| ]), | |
| _nodes: [{ id: 3 }, { id: 8 }, { id: 10 }, { id: 11 }], | |
| }, | |
| } | |
| graphToPrompt.mockResolvedValue({ | |
| workflow: { nodes: [{ id: 3 }, { id: 8 }, { id: 10 }, { id: 11 }] }, | |
| output: { | |
| '8': { | |
| inputs: { project_name: ['3', 0], project_data: JSON.stringify(projectData) }, | |
| class_type: 'easy multitrackProjectVideoCombine', | |
| }, | |
| '10': { | |
| inputs: { | |
| input_mode: { | |
| input_mode: 'video', | |
| video: ['8', 0], | |
| audio: ['11', 0], | |
| }, | |
| filename_prefix: ['8', 1], | |
| }, | |
| class_type: 'easy saveVideo', | |
| }, | |
| }, | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} node={reloadedNode} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Combine' })) | |
| await waitFor(() => expect(api.queuePrompt).toHaveBeenCalledTimes(1)) | |
| expect(api.queuePrompt.mock.calls[0][1].output['10'].inputs).toEqual({ | |
| input_mode: { input_mode: 'video', video: ['8', 0] }, | |
| filename_prefix: ['8', 1], | |
| }) | |
| }) | |
| it('serializes the auto combine checkbox through project data', () => { | |
| const { props } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('checkbox', { name: 'Auto combine' })) | |
| expect(props.onChange).toHaveBeenCalledWith({ ...projectData, auto_combine: false }) | |
| }) | |
| it('exposes project playback to the shared sync play action', async () => { | |
| const { props } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| await act(async () => { | |
| await (props.node as typeof props.node & { __easyMediaSyncPlay?: (startAt: number) => void }) | |
| .__easyMediaSyncPlay?.(performance.now()) | |
| }) | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| expect(HTMLMediaElement.prototype.play).toHaveBeenCalled() | |
| }) | |
| it('toggles preview audio without changing project data', () => { | |
| const { props } = widgetProps() | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| const video = container.querySelector('video') as HTMLVideoElement | |
| expect(video.muted).toBe(false) | |
| fireEvent.click(screen.getByRole('button', { name: 'Mute preview audio' })) | |
| expect(video.muted).toBe(true) | |
| expect(screen.getByRole('button', { name: 'Unmute preview audio' })).not.toBeNull() | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| }) | |
| it('accepts the shared sync play mute decision', async () => { | |
| const { props } = widgetProps() | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| await act(async () => { | |
| await (props.node as typeof props.node & { | |
| __easyMediaSyncPlay?: (startAt: number, muted?: boolean) => void | |
| }).__easyMediaSyncPlay?.(performance.now(), true) | |
| }) | |
| expect((container.querySelector('video') as HTMLVideoElement).muted).toBe(true) | |
| expect(screen.getByRole('button', { name: 'Unmute preview audio' })).not.toBeNull() | |
| }) | |
| it('seeks the preview video when the playhead moves within the same clip', () => { | |
| const { props } = widgetProps() | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| const video = container.querySelector('video') as HTMLVideoElement | |
| const ruler = container.querySelector('.cursor-col-resize') as HTMLDivElement | |
| vi.spyOn(ruler, 'getBoundingClientRect').mockReturnValue({ | |
| left: 0, | |
| width: 480, | |
| right: 480, | |
| top: 0, | |
| bottom: 24, | |
| height: 24, | |
| x: 0, | |
| y: 0, | |
| toJSON: () => ({}), | |
| }) | |
| fireEvent.mouseDown(ruler, { clientX: 230 }) | |
| expect(video.currentTime).toBeGreaterThan(2) | |
| fireEvent.mouseUp(window) | |
| }) | |
| it('keeps the preloaded next video element mounted when crossing a clip boundary', () => { | |
| const secondClip = { | |
| ...projectData.clips[0], | |
| id: 'segment-1', | |
| index: 1, | |
| file_path: 'easy_media/projects/demo/video_1_1.mp4', | |
| file_name: 'video_1_1.mp4', | |
| media_revision: '1000000002', | |
| source_start_frame: 24, | |
| } | |
| const { props } = widgetProps({ | |
| value: { ...projectData, clips: [projectData.clips[0], secondClip] }, | |
| }) | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| const videosBefore = [...container.querySelectorAll('video')] | |
| const preloadedSecondVideo = videosBefore[1] | |
| expect(preloadedSecondVideo.currentTime).toBeCloseTo(1) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 2' })) | |
| const videosAfter = [...container.querySelectorAll('video')] | |
| expect(videosAfter[0]).toBe(preloadedSecondVideo) | |
| expect(videosAfter[0].getAttribute('aria-hidden')).toBe('false') | |
| }) | |
| it('loops playback back to frame zero after reaching the project end', () => { | |
| let animationFrame: FrameRequestCallback | undefined | |
| vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { | |
| animationFrame = callback | |
| return 1 | |
| })) | |
| vi.stubGlobal('cancelAnimationFrame', vi.fn()) | |
| const { props } = widgetProps() | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Play' })) | |
| const clockStart = performance.now() | |
| act(() => animationFrame?.(clockStart + 2000)) | |
| expect(container.querySelector('.text-gradient')?.textContent).not.toBe('00:00:00') | |
| act(() => animationFrame?.(clockStart + 6000)) | |
| expect(container.querySelector('.text-gradient')?.textContent).toBe('00:00:00') | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| }) | |
| it('stops preview playback when the browser page becomes hidden', () => { | |
| const { props } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Play' })) | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| Object.defineProperty(document, 'visibilityState', { | |
| configurable: true, | |
| value: 'hidden', | |
| }) | |
| fireEvent(document, new Event('visibilitychange')) | |
| expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() | |
| expect(screen.getByRole('button', { name: 'Play' })).not.toBeNull() | |
| }) | |
| it('stops only this project preview when the active ComfyUI workflow tab changes', () => { | |
| let workflowSubscriber: ((mutation: unknown, state: { | |
| activeWorkflow: { key: string } | |
| }) => void) | undefined | |
| const { props, workflow } = widgetProps() | |
| workflow.$subscribe.mockImplementation((subscriber) => { | |
| workflowSubscriber = subscriber | |
| return vi.fn() | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Play' })) | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| act(() => { | |
| workflow.activeWorkflow = { key: 'workflow-b' } | |
| workflowSubscriber?.({}, { activeWorkflow: workflow.activeWorkflow }) | |
| }) | |
| expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() | |
| expect(screen.getByRole('button', { name: 'Play' })).not.toBeNull() | |
| }) | |
| it('uses one-based labels and shows the continuity mode', () => { | |
| const { props } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| expect(screen.getByText('Segment 1')).not.toBeNull() | |
| expect(screen.getByText('Shot')).not.toBeNull() | |
| expect(screen.queryByRole('combobox', { name: 'Select a file for segment 1' })).toBeNull() | |
| }) | |
| it('keeps timeline content mounted while hiding and showing the track area', () => { | |
| const { props } = widgetProps() | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| const segmentLabel = screen.getByText('Segment 1') | |
| fireEvent.click(screen.getByRole('button', { name: 'Hide timeline' })) | |
| expect(segmentLabel.isConnected).toBe(true) | |
| expect(screen.getByText('Segment 1')).toBe(segmentLabel) | |
| fireEvent.click(screen.getByRole('button', { name: 'Show timeline' })) | |
| expect(screen.getByText('Segment 1')).toBe(segmentLabel) | |
| }) | |
| it('allows selecting two videos for comparison and promotes the remaining video for output', () => { | |
| const alternatePath = 'easy_media/projects/demo/video_0_2.mp4' | |
| const data: ProjectData = { | |
| ...projectData, | |
| clips: [{ | |
| ...projectData.clips[0], | |
| video_files: [ | |
| { | |
| file_path: projectData.clips[0].file_path, | |
| file_name: projectData.clips[0].file_name, | |
| media_revision: projectData.clips[0].media_revision, | |
| source_frame_count: 120, | |
| }, | |
| { | |
| file_path: alternatePath, | |
| file_name: 'video_0_2.mp4', | |
| media_revision: '1000000002', | |
| source_frame_count: 96, | |
| continuity_mode: 'context_swap', | |
| }, | |
| { | |
| file_path: 'easy_media/projects/demo/video_0_3.mp4', | |
| file_name: 'video_0_3.mp4', | |
| media_revision: '1000000003', | |
| source_frame_count: 72, | |
| }, | |
| ], | |
| }], | |
| } | |
| const { props, api } = widgetProps({ value: data }) | |
| api.fetchApi.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(data) }) | |
| const { container } = render(<ProjectVideoCombineWidget {...props} />) | |
| const fileSelect = screen.getByRole('button', { name: 'Select up to two videos for segment 1' }) | |
| expect(fileSelect.className).toContain('justify-center') | |
| expect(fileSelect.firstElementChild?.className).toContain('flex-col') | |
| fireEvent.click(fileSelect) | |
| const original = screen.getByRole('checkbox', { name: 'video_0_1.mp4' }) | |
| const alternate = screen.getByRole('checkbox', { name: 'video_0_2.mp4' }) | |
| expect(original.getAttribute('aria-checked')).toBe('true') | |
| expect(alternate.getAttribute('aria-checked')).toBe('false') | |
| fireEvent.click(alternate) | |
| expect(container.querySelectorAll('video')).toHaveLength(2) | |
| expect(screen.getByRole('button', { name: 'A/B' })).not.toBeNull() | |
| expect((screen.getByRole('checkbox', { name: 'video_0_3.mp4' }) as HTMLButtonElement).disabled).toBe(true) | |
| expect(screen.queryByRole('button', { name: 'Select source' })).toBeNull() | |
| expect(screen.queryByRole('button', { name: 'Select output' })).toBeNull() | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| expect((screen.getByRole('button', { name: 'Delete video_0_3.mp4' }) as HTMLButtonElement).disabled).toBe(false) | |
| fireEvent.click(original) | |
| expect(props.onChange).toHaveBeenCalledWith({ | |
| ...data, | |
| clips: [expect.objectContaining({ | |
| file_path: alternatePath, | |
| file_name: 'video_0_2.mp4', | |
| media_revision: '1000000002', | |
| source_end_frame: 96, | |
| source_frame_count: 96, | |
| continuity_mode: 'context_swap', | |
| })], | |
| }) | |
| }) | |
| it('shows the continuity mode saved with the selected generation', () => { | |
| const alternate = { | |
| file_path: 'easy_media/projects/demo/video_0_2.mp4', | |
| file_name: 'video_0_2.mp4', | |
| source_frame_count: 96, | |
| continuity_mode: 'context_swap' as const, | |
| } | |
| const initial = { | |
| ...projectData, | |
| clips: [{ | |
| ...projectData.clips[0], | |
| video_files: [projectData.clips[0], alternate], | |
| }], | |
| } | |
| const { props, api } = widgetProps({ value: initial }) | |
| api.fetchApi.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(initial) }) | |
| function Widget() { | |
| const [value, setValue] = useState<ProjectData>(initial) | |
| return <ProjectVideoCombineWidget {...props} value={value} onChange={setValue} /> | |
| } | |
| render(<Widget />) | |
| const fileSelect = screen.getByRole('button', { name: 'Select up to two videos for segment 1' }) | |
| fireEvent.click(fileSelect) | |
| const original = screen.getByRole('checkbox', { name: projectData.clips[0].file_name }) | |
| fireEvent.click(screen.getByRole('checkbox', { name: alternate.file_name })) | |
| fireEvent.click(original) | |
| expect(screen.getByText('Swap Context')).not.toBeNull() | |
| }) | |
| it('persists the primary video selection for the next segment context', async () => { | |
| const alternate = { | |
| file_path: 'easy_media/projects/demo/video_0_2.mp4', | |
| file_name: 'video_0_2.mp4', | |
| source_frame_count: 96, | |
| } | |
| const initial = { | |
| ...projectData, | |
| clips: [{ | |
| ...projectData.clips[0], | |
| video_files: [projectData.clips[0], alternate], | |
| }], | |
| } | |
| const { props, api } = widgetProps({ value: initial }) | |
| api.fetchApi.mockResolvedValue({ | |
| ok: true, | |
| json: vi.fn().mockResolvedValue(initial), | |
| }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('checkbox', { name: alternate.file_name })) | |
| fireEvent.click(screen.getByRole('checkbox', { name: projectData.clips[0].file_name })) | |
| await waitFor(() => expect(api.fetchApi).toHaveBeenCalledWith( | |
| '/easy-media/project/video', | |
| { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| project_name: 'demo', | |
| segment_index: 0, | |
| file_path: alternate.file_path, | |
| }), | |
| }, | |
| )) | |
| }) | |
| it('cancels deletion without changing selection or bubbling the delete click', async () => { | |
| const { props, api, dialog } = widgetProps() | |
| dialog.confirm.mockResolvedValue(false) | |
| const bubbled = vi.fn() | |
| render(<div onClick={bubbled}><ProjectVideoCombineWidget {...props} /></div>) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| bubbled.mockClear() | |
| const deleteButton = screen.getByRole('button', { name: 'Delete video_0_1.mp4' }) | |
| fireEvent.pointerDown(deleteButton) | |
| fireEvent.click(deleteButton) | |
| await waitFor(() => expect(dialog.confirm).toHaveBeenCalledWith({ | |
| title: 'Delete generated clip?', | |
| message: expect.stringContaining('video_0_1.mp4'), | |
| })) | |
| expect(bubbled).not.toHaveBeenCalled() | |
| expect(api.fetchApi).not.toHaveBeenCalled() | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| expect(screen.getByRole('checkbox', { name: 'video_0_1.mp4' }).getAttribute('aria-checked')).toBe('true') | |
| }) | |
| it('deletes the selected version and keeps the remaining comparison video and trim', async () => { | |
| const alternate = { | |
| file_path: 'easy_media/projects/demo/video_0_2.mp4', | |
| file_name: 'video_0_2.mp4', | |
| source_frame_count: 96, | |
| } | |
| const clip = { ...projectData.clips[0], source_start_frame: 12, video_files: [projectData.clips[0], alternate] } | |
| const { props, api } = widgetProps({ value: { ...projectData, clips: [clip], auto_combine: false } }) | |
| api.fetchApi.mockResolvedValue({ | |
| ok: true, | |
| json: async () => ({ ...projectData, updated_at: 123, clips: [{ ...clip, ...alternate, video_files: [alternate] }] }), | |
| }) | |
| const { rerender, container } = render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('checkbox', { name: 'video_0_2.mp4' })) | |
| expect(container.querySelectorAll('video')).toHaveLength(2) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalledWith(expect.objectContaining({ | |
| auto_combine: false, | |
| updated_at: 123, | |
| clips: [expect.objectContaining({ ...alternate, source_start_frame: 12, source_end_frame: 96, video_files: [alternate] })], | |
| }))) | |
| expect(api.fetchApi).toHaveBeenCalledWith('/easy-media/project/video', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ project_name: 'demo', segment_index: 0, file_path: clip.file_path }), | |
| }) | |
| const nextData = vi.mocked(props.onChange).mock.calls.at(-1)![0] | |
| rerender(<ProjectVideoCombineWidget {...props} value={nextData} />) | |
| expect(screen.queryByRole('checkbox', { name: 'video_0_1.mp4' })).toBeNull() | |
| expect(container.querySelectorAll('video')).toHaveLength(1) | |
| }) | |
| it('removes the last generated clip after confirmation', async () => { | |
| const { props, api } = widgetProps() | |
| api.fetchApi.mockResolvedValue({ ok: true, json: async () => ({ ...projectData, clips: [], updated_at: 123 }) }) | |
| const { rerender } = render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalledWith({ ...projectData, clips: [], updated_at: 123 })) | |
| rerender(<ProjectVideoCombineWidget {...props} value={{ ...projectData, clips: [], updated_at: 123 }} />) | |
| expect(screen.queryByText('Segment 1')).toBeNull() | |
| }) | |
| it.each([0, 1])('returns to normal preview after deleting comparison video %s', async (deletedIndex) => { | |
| const files = [projectData.clips[0], { | |
| file_path: 'easy_media/projects/demo/video_0_2.mp4', | |
| file_name: 'video_0_2.mp4', | |
| source_frame_count: 96, | |
| }] | |
| const initial = { ...projectData, clips: [{ ...projectData.clips[0], video_files: files }] } | |
| const remaining = files[1 - deletedIndex] | |
| const { props, api } = widgetProps({ value: initial }) | |
| api.fetchApi.mockResolvedValue({ | |
| ok: true, | |
| json: async () => ({ ...initial, clips: [{ ...initial.clips[0], ...remaining, video_files: [remaining] }] }), | |
| }) | |
| function Widget() { | |
| const [value, setValue] = useState<ProjectData>(initial) | |
| return <ProjectVideoCombineWidget {...props} value={value} onChange={(next) => { | |
| props.onChange(next) | |
| setValue(next) | |
| }} /> | |
| } | |
| const { container } = render(<Widget />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('checkbox', { name: files[1].file_name })) | |
| const controls = container.querySelector('[data-compare-video-playback]') as HTMLElement | |
| const comparisonVideos = [...container.querySelectorAll('video')] | |
| fireEvent.click(within(controls).getByRole('button', { name: 'Play' })) | |
| fireEvent.click(screen.getByRole('button', { name: `Delete ${files[deletedIndex].file_name}` })) | |
| await waitFor(() => expect(props.onChange).toHaveBeenCalled()) | |
| await waitFor(() => expect(container.querySelector('[data-compare-video-playback]')).toBeNull()) | |
| expect(screen.queryByRole('button', { name: 'A/B' })).toBeNull() | |
| expect(container.querySelectorAll('video')).toHaveLength(1) | |
| const preview = container.querySelector('video')! | |
| expect(preview.src).toContain(remaining.file_name) | |
| expect(preview.getAttribute('aria-hidden')).toBe('false') | |
| for (const video of comparisonVideos) { | |
| expect(vi.mocked(HTMLMediaElement.prototype.pause).mock.contexts).toContain(video) | |
| } | |
| fireEvent.click(screen.getByRole('button', { name: 'Play' })) | |
| expect(vi.mocked(HTMLMediaElement.prototype.play).mock.contexts).toContain(preview) | |
| expect(screen.getByRole('button', { name: 'Pause' })).not.toBeNull() | |
| }) | |
| it('keeps project data unchanged and reports a deletion failure', async () => { | |
| const { props, api, toast } = widgetProps() | |
| api.fetchApi.mockResolvedValue({ ok: false, json: async () => ({ error: 'Could not save project' }) }) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', summary: 'Clip deletion failed', detail: 'Could not save project', | |
| }))) | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| expect((screen.getByRole('button', { name: 'Delete video_0_1.mp4' }) as HTMLButtonElement).disabled).toBe(false) | |
| }) | |
| it.each([404, 405])('explains that the backend must be restarted for a plain-text HTTP %s response', async (status) => { | |
| const { props, api, toast } = widgetProps() | |
| api.fetchApi.mockResolvedValue(new Response(`${status}: Not Found`, { status })) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', detail: expect.stringContaining('Restart ComfyUI'), | |
| }))) | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| expect(api.fetchApi).toHaveBeenCalledTimes(1) | |
| }) | |
| it.each([ | |
| { body: '<html>Bad Gateway</html>', status: 502 }, | |
| { body: '<html>Fallback page</html>', status: 200 }, | |
| { body: '{"error":"Unexpected payload"}', status: 200 }, | |
| { body: '{"project_name":"other","clips":[]}', status: 200 }, | |
| ])('preserves project state for an invalid deletion response: $status $body', async ({ body, status }) => { | |
| const { props, api, toast } = widgetProps() | |
| api.fetchApi.mockResolvedValue(new Response(body, { status })) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', detail: expect.stringContaining(`invalid project response (HTTP ${status})`), | |
| }))) | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| }) | |
| it('preserves a JSON 404 error from the registered deletion endpoint', async () => { | |
| const { props, api, toast } = widgetProps() | |
| api.fetchApi.mockResolvedValue(new Response(JSON.stringify({ error: 'Project manifest was not found' }), { status: 404 })) | |
| render(<ProjectVideoCombineWidget {...props} />) | |
| fireEvent.click(screen.getByRole('button', { name: 'Select up to two videos for segment 1' })) | |
| fireEvent.click(screen.getByRole('button', { name: 'Delete video_0_1.mp4' })) | |
| await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ | |
| severity: 'error', detail: 'Project manifest was not found', | |
| }))) | |
| expect(props.onChange).not.toHaveBeenCalled() | |
| }) | |
| }) | |