import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createDefaultTrackData } from '@/lib/multitrack-utils' import type { ReactWidgetProps } from '@/lib/create-react-widget' import type { MultiTrackType, TrackData } from '@/types/multitrack' import { MultiTrackWidget } from '@/components/widgets/MultiTrackWidget' import { loadBrowserAudioMetadata } from '@/lib/audio-utils' import { loadBrowserVideoMetadata } from '@/lib/video-utils' vi.mock('@/hooks/use-canvas-scale', () => ({ useCanvasScale: () => 1, })) vi.mock('@/hooks/use-element-width', () => ({ useElementWidth: () => 480, })) vi.mock('@/lib/audio-utils', () => ({ loadBrowserAudioMetadata: vi.fn().mockResolvedValue({ duration: 2 }), })) vi.mock('@/lib/video-utils', () => ({ loadBrowserVideoMetadata: vi.fn(), })) vi.mock('@/components/widgets/multitrack/PreviewArea', () => ({ PreviewArea: ({ selectedSegment, selectedTaskSegments, isPlaying, playbackNonce, onSelectedSegmentContentChange, onTrackSegmentsContentChange, onGenerateSubtitleSpeech }: { selectedSegment: { trackType: string; segment: TrackData['tracks'][number]['segments'][number] } | null selectedTaskSegments?: TrackData['tracks'][number]['segments'] isPlaying: boolean playbackNonce?: number onSelectedSegmentContentChange: (patch: unknown) => void onTrackSegmentsContentChange: (updates: Array<{ segmentId: string; patch: unknown }>) => void onGenerateSubtitleSpeech?: ( segment: TrackData['tracks'][number]['segments'][number], settings: { model: 'VoxCPM2' prompt: string cfg: number steps: number referenceAudio: string referenceAudioSourceType: 'input' }, ) => Promise }) => (
{String(isPlaying)} {playbackNonce ?? 0} {selectedTaskSegments?.length ?? 0} {selectedSegment?.trackType === 'subtitle' ? ( ) : null} {selectedSegment?.trackType === 'subtitle' ? ( ) : null} {selectedSegment?.trackType === 'task' ? (
{selectedSegment.segment.content.user_prompt ?? ''}
) : null} {!selectedSegment ? ( ) : null}
), })) vi.mock('@/components/widgets/multitrack/MultiTrackRuler', () => ({ MultiTrackRuler: ({ totalLength, onSeek, taskMarkers, onMoveTaskMarker, onDeleteTaskMarker }: { totalLength: number onSeek: (time: number) => void taskMarkers: TrackData['task_markers'] onMoveTaskMarker: (markerId: string, frame: number) => void onDeleteTaskMarker: (markerId: string) => void }) => (
{taskMarkers?.length ?? 0} {taskMarkers?.[0] ? ( <> ) : null}
), })) vi.mock('@/components/widgets/multitrack/TrackArea', () => ({ TrackArea: ({ data, node, app, onCloneTaskSegment, onSplitTaskSegment, onAddTrack, onDeleteTrack, onReorderTrack = () => {}, onAddVideo, onAddAudio, onReplaceAudio, onAddTaskSegment, onAddSubtitleSegment, onImportSubtitles, onSmartSplit, onSmartSplitTasks, onRecognizeSubtitles, onResizeSegment, onResizeSegmentPreview, onMoveSegment, onTrackAudioSettingsChange, selectedSegmentIds, onSelectSegment, onSelectSegments, cutMode, taskOverview }: { data: TrackData node: unknown app: unknown onCloneTaskSegment: (trackId: string, segmentId: string) => void onSplitTaskSegment: (segmentId: string) => void onAddTrack: (type: MultiTrackType) => void onDeleteTrack: (trackId: string) => void onReorderTrack?: (sourceTrackId: string, targetTrackId: string) => void onAddVideo: (trackId: string, filePath: string, sourceType: 'input', startFrame?: number, endFrame?: number, previewUrl?: string) => void onAddAudio: (trackId: string, filePath: string, sourceType: 'input', previewUrl?: string, startFrame?: number, endFrame?: number) => void onReplaceAudio: (trackId: string, segmentId: string, filePath: string, sourceType: 'input') => void onAddTaskSegment: ( trackId: string, startFrame?: number, endFrame?: number, images?: TrackData['tracks'][number]['segments'][number]['content']['images'], ) => void onAddSubtitleSegment: (trackId: string, startFrame?: number, endFrame?: number) => void onImportSubtitles: (trackId: string, srtText: string, startFrame: number) => void onSmartSplit: (segmentId: string) => void onSmartSplitTasks: (segmentId: string) => void onRecognizeSubtitles: (segmentId: string, method: 'qwen3-asr' | 'whisper-large-v3') => void onResizeSegment: (segmentId: string, edge: 'start' | 'end', nextTime: number, brakeDistanceFrames?: number) => void onResizeSegmentPreview: (segmentId: string, edge: 'start' | 'end', nextTime: number, brakeDistanceFrames?: number) => void onMoveSegment: (segmentId: string, targetTrackId: string, nextStartTime: number) => void onTrackAudioSettingsChange: (trackId: string, patch: { muted?: boolean; solo?: boolean }) => void selectedSegmentIds: Set onSelectSegment: (segmentId: string) => void onSelectSegments: (segmentIds: string[]) => void cutMode: boolean taskOverview: boolean }) => { const taskTrack = data.tracks.find((track) => track.type === 'task') const audioTrack = data.tracks.find((track) => track.type === 'audio') const audioSegment = audioTrack?.segments[0] const segment = taskTrack?.segments[0] const videoSegment = data.tracks.find((track) => track.type === 'video')?.segments[0] const firstVideoTrack = data.tracks.find((track) => track.type === 'video') const deletableTrack = data.tracks.find((track) => track.type !== 'task' && track.id !== firstVideoTrack?.id) const subtitleTrack = data.tracks.find((track) => track.type === 'subtitle') const subtitleSegment = data.tracks.find((track) => track.type === 'subtitle')?.segments[0] return (
{selectedSegmentIds.size}
{node && app ? 'connected' : 'missing'}
{taskTrack && segment ? ( ) : null} {firstVideoTrack && videoSegment ? ( ) : null} {audioTrack && audioSegment ? ( ) : null} {taskTrack && segment ? ( ) : null} {taskTrack && segment ? ( ) : null} {taskTrack?.segments[1] ? ( <> ) : null} {data.tracks.filter((track) => track.type !== 'task').length >= 2 ? ( ) : null} {deletableTrack ? ( ) : null} {taskTrack ? ( ) : null} {taskTrack ? ( ) : null} {audioTrack ? ( ) : null} {audioTrack?.segments[0] ? ( ) : null} {firstVideoTrack ? ( ) : null} {audioTrack ? ( ) : null} {videoSegment ? ( ) : null} {videoSegment ? ( ) : null} {subtitleTrack ? ( ) : null} {subtitleTrack ? ( ) : null} {subtitleTrack ? ( ) : null} {subtitleTrack ? ( ) : null} {videoSegment ? ( ) : null} {subtitleSegment ? ( ) : null} {subtitleSegment ? ( ) : null} {subtitleTrack && subtitleSegment ? ( ) : null} {videoSegment && segment ? ( ) : null} {videoSegment ? ( ) : null} {videoSegment ? ( ) : null} {videoSegment ? ( ) : null} {videoSegment ? ( ) : null} {audioSegment ? ( ) : null} {segment ? ( ) : null} {data.tracks[1]?.segments.length >= 2 ? ( ) : null} {videoSegment ? ( ) : null} {audioTrack ? ( ) : null} {audioTrack ? ( ) : null}
) }, })) vi.mock('@/components/widgets/multitrack/MultiTrackToolbar', () => ({ MultiTrackToolbar: ({ onToggleTimeline, onAddTaskMarker, canAddTaskMarker, onTaskOverviewChange, taskOverview, canDelete, onDeleteSelected, onCutAtCurrentTime, canTrimLeft, canTrimRight, onTrimLeftAtCurrentTime, onTrimRightAtCurrentTime, canUndo, canRedo, onUndo, onRedo, onPlayPause, currentTime, }: { onToggleTimeline: () => void onAddTaskMarker: () => void canAddTaskMarker: boolean onTaskOverviewChange: (enabled: boolean) => void taskOverview: boolean canDelete: boolean onDeleteSelected: () => void onCutAtCurrentTime: () => void canTrimLeft: boolean canTrimRight: boolean onTrimLeftAtCurrentTime: () => void onTrimRightAtCurrentTime: () => void canUndo: boolean canRedo: boolean onUndo: () => void onRedo: () => void onPlayPause: () => void currentTime: number }) => (
{currentTime}
), })) function widgetProps(): ReactWidgetProps { return { value: createDefaultTrackData(), onChange: vi.fn(), inputName: 'tracks', node: {}, widget: {} as ReactWidgetProps['widget'], app: { ui: { settings: { settingsValues: {} } }, } as ReactWidgetProps['app'], } } function resizableNode(initialHeight: number) { const node = { size: [800, initialHeight] as [number, number], properties: {} as Record, setDirtyCanvas: vi.fn(), setSize: vi.fn((size: [number, number]) => { node.size = size }), } return node } describe('MultiTrackWidget', () => { beforeEach(() => { vi.mocked(loadBrowserVideoMetadata).mockResolvedValue({ duration: 1, width: 1920, height: 1080 }) }) it('fills a requested video gap without moving the following clip', async () => { const data = createDefaultTrackData() data.total_length = 120 data.tracks[1].segments = [ { id: 'video-first', start_frame: 0, end_frame: 24, color: data.tracks[1].color, content: { media_type: 'video', duration: 1 }, }, { id: 'video-second', start_frame: 72, end_frame: 96, color: data.tracks[1].color, content: { media_type: 'video', duration: 1 }, }, ] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'fill video gap' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[1].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 24], [24, 72], [72, 96], ]) }) it('fills requested task and subtitle gaps with their full ranges', () => { const data = createDefaultTrackData() data.total_length = 120 data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'ref', continuity_mode: 'context', ref_image_size: 'max', system_prompt: 'Keep this custom prompt', }, }, { id: 'task-second', start_frame: 72, end_frame: 96, color: data.tracks[0].color, content: { media_type: 'none' } }, ] data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 1', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [ { id: 'subtitle-first', start_frame: 0, end_frame: 24, color: '#9D4937', content: { media_type: 'subtitle', text: 'First' } }, { id: 'subtitle-second', start_frame: 72, end_frame: 96, color: '#9D4937', content: { media_type: 'subtitle', text: 'Second' } }, ], }) const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'fill task gap' })) const taskUpdated = onChange.mock.lastCall?.[0] as TrackData expect(taskUpdated.tracks[0].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 24], [24, 72], [72, 96], ]) expect(taskUpdated.tracks[0].segments[1].content).toMatchObject({ task_mode: 'ref', system_prompt: 'Keep this custom prompt', continuity_mode: 'context', ref_image_size: 'max', }) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'fill subtitle gap' })) const subtitleUpdated = onChange.mock.lastCall?.[0] as TrackData expect(subtitleUpdated.tracks.at(-1)?.segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 24], [24, 72], [72, 96], ]) }) it('creates a task segment containing a dropped image', () => { const data = createDefaultTrackData() data.tracks[0].segments = [] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'drop task image' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments).toHaveLength(1) expect(updated.tracks[0].segments[0]).toMatchObject({ start_frame: 24, content: { media_type: 'none', images: [{ source_type: 'input', file_path: 'uploads/reference.png', file_name: 'reference.png', }], }, }) }) it.each([ ['trim video start', 2, 10], ['trim video end', 0, 8], ])('syncs a matching task when using %s', (buttonName, expectedStart, expectedEnd) => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-matching', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-main', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: buttonName })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments[0]).toMatchObject({ start_frame: expectedStart, end_frame: expectedEnd, }) }) it('records a single history entry when resizing after transient previews', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-matching', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-main', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'preview trim video end' })) expect(onChange).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'trim video end' })) expect(onChange).toHaveBeenCalledOnce() const resized = onChange.mock.lastCall?.[0] as TrackData expect(resized.tracks[1].segments[0].end_frame).toBe(8) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'undo history' })) const undone = onChange.mock.lastCall?.[0] as TrackData expect(undone.tracks[1].segments[0].end_frame).toBe(10) }) it('increases total length when dragging the end of an earlier task', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-second', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'extend first task end' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 36], [36, 60], ]) expect(updated.total_length).toBe(60) }) it('animates the ruler and track region to zero height when toggled', () => { render() const timelinePanel = screen.getByTestId('multitrack-timeline-panel') expect(timelinePanel.className).toContain('grid-rows-[1fr]') expect(timelinePanel.className).toContain('transition-[grid-template-rows]') fireEvent.click(screen.getByRole('button', { name: 'toggle timeline' })) expect(timelinePanel.className).toContain('grid-rows-[0fr]') expect(timelinePanel.getAttribute('aria-hidden')).toBe('true') }) it('serializes task markers at the playhead, deletes them from the ruler, and toggles overview', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task', start_frame: 0, end_frame: 120, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'add task marker' })) const marked = onChange.mock.lastCall?.[0] as TrackData expect(marked.task_markers).toHaveLength(1) expect(marked.task_markers?.[0]).toMatchObject({ frame: 5 }) view.rerender() expect(screen.getByTestId('task-marker-count').textContent).toBe('1') fireEvent.click(screen.getByRole('button', { name: 'move first marker' })) const moved = onChange.mock.lastCall?.[0] as TrackData expect(moved.task_markers?.[0].frame).toBe(7) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'toggle task overview' })) const overviewed = onChange.mock.lastCall?.[0] as TrackData expect(overviewed.task_overview).toBe(true) view.rerender() expect(screen.getByTestId('multitrack-track-area').getAttribute('data-task-overview')).toBe('true') fireEvent.click(screen.getByRole('button', { name: 'delete first marker' })) const unmarked = onChange.mock.lastCall?.[0] as TrackData expect(unmarked.task_markers).toEqual([]) }) it('restores task overview mode from serialized widget data', () => { const data = createDefaultTrackData() data.task_overview = true render() expect(screen.getByTestId('multitrack-track-area').getAttribute('data-task-overview')).toBe('true') }) it('jumps the playhead to a task start when selecting a task outside the current time', () => { const data = createDefaultTrackData() data.total_length = 48 data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-second', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] render() fireEvent.click(screen.getByRole('button', { name: 'select second task segment' })) expect(screen.getByTestId('toolbar-current-time').textContent).toBe('24') }) it('keeps the task editor visible when every selected segment is a task', () => { const data = createDefaultTrackData() data.total_length = 48 data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'First' }, }, { id: 'task-second', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Second' }, }, ] render() fireEvent.click(screen.getByRole('button', { name: 'select first two task segments' })) expect(screen.getByTestId('preview-selected-task-count').textContent).toBe('2') expect(screen.getByTestId('selected-task-user-prompt').textContent).toBe('Second') }) it('switches the selected task when seeking into another task range', () => { const data = createDefaultTrackData() data.total_length = 10 data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'First' }, }, { id: 'task-second', start_frame: 5, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Second' }, }, ] render() fireEvent.click(screen.getByRole('button', { name: 'select task segment' })) fireEvent.click(screen.getByTestId('multitrack-ruler')) expect(screen.getByTestId('selected-task-user-prompt').textContent).toBe('Second') }) it('switches the selected task when playback enters another task range', () => { const data = createDefaultTrackData() data.frame_rate = 10 data.total_length = 20 data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'First' }, }, { id: 'task-second', start_frame: 5, end_frame: 20, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Second' }, }, ] let animationFrame: FrameRequestCallback | null = null vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { animationFrame = callback return 1 })) vi.stubGlobal('cancelAnimationFrame', vi.fn()) render() fireEvent.click(screen.getByRole('button', { name: 'select task segment' })) fireEvent.click(screen.getByRole('button', { name: 'toggle playback' })) expect(animationFrame).not.toBeNull() act(() => animationFrame?.(performance.now() + 600)) expect(screen.getByTestId('selected-task-user-prompt').textContent).toBe('Second') vi.unstubAllGlobals() }) it('loops playback to frame zero and restarts preview media at the timeline end', () => { const data = createDefaultTrackData() data.frame_rate = 10 data.total_length = 10 data.tracks[0].segments = [{ id: 'loop-task', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none' }, }] let animationFrame: FrameRequestCallback | null = null const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { animationFrame = callback return requestAnimationFrame.mock.calls.length }) vi.stubGlobal('requestAnimationFrame', requestAnimationFrame) vi.stubGlobal('cancelAnimationFrame', vi.fn()) render() fireEvent.click(screen.getByRole('button', { name: 'toggle playback' })) expect(animationFrame).not.toBeNull() act(() => animationFrame?.(performance.now() + 1100)) expect(screen.getByTestId('toolbar-current-time').textContent).toBe('0') expect(screen.getByTestId('preview-is-playing').textContent).toBe('true') expect(screen.getByTestId('preview-playback-nonce').textContent).toBe('1') expect(requestAnimationFrame.mock.calls.length).toBeGreaterThan(1) vi.unstubAllGlobals() }) it('starts shared sync play without changing the global audio value', () => { const data = createDefaultTrackData() const node = {} const onChange = vi.fn() render() act(() => { (node as { __easyMediaSyncPlay?: (startAt: number, muted?: boolean) => void }) .__easyMediaSyncPlay?.(performance.now(), true) }) expect(onChange).not.toHaveBeenCalled() expect(screen.getByTestId('preview-is-playing').textContent).toBe('true') }) it('allows a task marker at the timeline end', () => { const data = createDefaultTrackData() const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'seek timeline end' })) fireEvent.click(screen.getByRole('button', { name: 'add task marker' })) const marked = onChange.mock.lastCall?.[0] as TrackData expect(marked.task_markers).toEqual([ expect.objectContaining({ frame: data.total_length }), ]) }) it('undoes and redoes multitrack widget changes without using canvas history', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-0', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'clone task' })) const cloned = onChange.mock.lastCall?.[0] as TrackData expect(cloned.tracks[0].segments).toHaveLength(2) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'undo history' })) const undone = onChange.mock.lastCall?.[0] as TrackData expect(undone.tracks[0].segments).toHaveLength(1) expect(undone.tracks[0].segments[0].id).toBe('task-0') view.rerender() fireEvent.click(screen.getByRole('button', { name: 'redo history' })) expect(onChange.mock.lastCall?.[0]).toEqual(cloned) }) it('clones subtitle segments from the subtitle context action', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 0', type: 'subtitle', color: 'var(--accent)', muted: false, locked: false, segments: [{ id: 'subtitle-0', start_frame: 12, end_frame: 36, color: 'var(--accent)', content: { media_type: 'subtitle', text: 'Hello' }, }], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'clone subtitle' })) const updated = onChange.mock.lastCall?.[0] as TrackData const subtitles = updated.tracks.at(-1)?.segments ?? [] expect(subtitles).toHaveLength(2) expect(subtitles[1]).toMatchObject({ start_frame: 36, end_frame: 60, content: { media_type: 'subtitle', text: 'Hello' }, }) expect(subtitles[1].id).not.toBe('subtitle-0') }) it('clones video and audio segments from their context actions', () => { const data = createDefaultTrackData() data.tracks[1].segments = [{ id: 'video-0', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [{ id: 'audio-0', start_frame: 5, end_frame: 15, color: 'var(--highlight)', content: { media_type: 'audio', source_type: 'input', file_path: 'clip.wav' }, }], }) const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'clone video' })) const videoCloned = onChange.mock.lastCall?.[0] as TrackData expect(videoCloned.tracks[1].segments.map((item) => [item.start_frame, item.end_frame])).toEqual([ [0, 10], [10, 20], ]) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'clone audio' })) const audioCloned = onChange.mock.lastCall?.[0] as TrackData expect(audioCloned.tracks.at(-1)?.segments.map((item) => [item.start_frame, item.end_frame])).toEqual([ [5, 15], [15, 25], ]) }) it('splits a task segment from the context dialog', () => { const data = createDefaultTrackData() data.frame_rate = 16 data.total_length = 160 data.tracks[0].segments = [{ id: 'task-0', start_frame: 0, end_frame: 160, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Prompt' }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'split task' })) const input = screen.getByRole('spinbutton', { name: 'Split seconds' }) expect((input as HTMLInputElement).value).toBe('5') fireEvent.change(input, { target: { value: '5' } }) fireEvent.blur(input) expect((input as HTMLInputElement).value).toBe('5') fireEvent.click(screen.getByRole('button', { name: 'Split' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments).toHaveLength(2) expect(updated.tracks[0].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 80], [80, 160], ]) expect(updated.tracks[0].segments.every((segment) => segment.content.user_prompt === 'Prompt')).toBe(true) }) it('updates the active task user prompt from the unselected preview area', () => { const data = createDefaultTrackData() data.frame_rate = 24 data.total_length = 48 data.tracks[0].segments = [ { id: 'task-before', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Before' }, }, { id: 'task-active', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Original active' }, }, ] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'update active preview prompt' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments[0].content.user_prompt).toBe('Before') expect(updated.tracks[0].segments[1].content.user_prompt).toBe('Preview prompt update') expect(updated.tracks[0].segments[1].content.text).toBeUndefined() }) it('shows preview-edited user prompt after selecting the same task segment', () => { const data = createDefaultTrackData() data.frame_rate = 24 data.total_length = 48 data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: '' }, }] const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'update active preview prompt' })) const updated = onChange.mock.lastCall?.[0] as TrackData view.rerender() fireEvent.click(screen.getByRole('button', { name: 'select task segment' })) expect(screen.getByTestId('selected-task-user-prompt').textContent).toBe('Preview prompt update') }) it('resets local redo history when the canvas restores a different widget value', () => { const initial = createDefaultTrackData() initial.tracks[0].segments = [{ id: 'task-0', start_frame: 0, end_frame: 10, color: initial.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'clone task' })) const cloned = onChange.mock.lastCall?.[0] as TrackData view.rerender() fireEvent.click(screen.getByRole('button', { name: 'undo history' })) expect(screen.getByRole('button', { name: 'redo history' }).disabled).toBe(false) const canvasRestored = { ...initial, frame_rate: 30, } view.rerender() expect(screen.getByRole('button', { name: 'redo history' }).disabled).toBe(true) }) it('keeps local history after dragging video segments to reorder them', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-0', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [ { id: 'video-0', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }, { id: 'video-1', start_frame: 10, end_frame: 20, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }, ] data.total_length = 20 const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'clone task' })) const withHistory = onChange.mock.lastCall?.[0] as TrackData view.rerender() expect(screen.getByRole('button', { name: 'undo history' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: 'move first video after second' })) const moved = onChange.mock.lastCall?.[0] as TrackData expect(moved.tracks[1].segments.map((segment) => segment.id)).toEqual(['video-1', 'video-0']) view.rerender() expect(screen.getByRole('button', { name: 'undo history' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: 'undo history' })) const undoneMove = onChange.mock.lastCall?.[0] as TrackData expect(undoneMove.tracks[1].segments.map((segment) => segment.id)).toEqual(['video-0', 'video-1']) }) it.each([ { name: 'uses the latest segment end when cloned tasks remain short', taskRanges: [[0, 2], [2, 4]], expectedTotalLength: 10, }, { name: 'extends the total length when cloned tasks exceed five seconds', taskRanges: [[0, 60], [60, 100]], expectedTotalLength: 160, }, ])('$name', ({ taskRanges, expectedTotalLength }) => { const data = createDefaultTrackData() data.total_length = 10 data.tracks[0].segments = taskRanges.map(([startFrame, endFrame], index) => ({ id: `task-${index}`, start_frame: startFrame, end_frame: endFrame, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default', user_prompt: `Task ${index}` }, })) data.tracks[1].segments = [{ id: 'video', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video' }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'clone task' })) expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ total_length: expectedTotalLength, })) }) it('adds an audio track and serializes audio segments with metadata duration', async () => { const onAddTrackChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'add audio track' })) const addedTrackData = onAddTrackChange.mock.lastCall?.[0] as TrackData const audioTrack = addedTrackData.tracks.at(-1) expect(audioTrack).toMatchObject({ name: 'Audio 0', type: 'audio', muted: false, solo: false, volume_db: 0 }) const onAddSegmentChange = vi.fn() render() fireEvent.click(screen.getAllByRole('button', { name: 'add audio segment' }).at(-1)!) await waitFor(() => expect(onAddSegmentChange).toHaveBeenCalled()) const updated = onAddSegmentChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.at(-1)?.segments[0]).toMatchObject({ start_frame: 0, end_frame: 48, content: { media_type: 'audio', file_path: 'audio.wav', duration: 2, volume_db: 0 }, }) }) it.each(['video', 'audio', 'subtitle'] as const)('adds a %s track below the three-track limit', (type) => { const data = createDefaultTrackData() const initialCount = data.tracks.filter((track) => track.type === type).length const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: `add ${type} track` })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.filter((track) => track.type === type)).toHaveLength(initialCount + 1) }) it('persists media track swaps while keeping the task track fixed', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'test-color', muted: false, locked: false, segments: [], }) const originalIds = data.tracks.map((track) => track.id) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'swap first two media tracks' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.map((track) => track.id)).toEqual([ originalIds[0], originalIds[2], originalIds[1], ]) }) it.each([ ['video', 764], ['audio', 764], ['subtitle', 730], ] as const)('grows the node when adding a %s track', (type, expectedHeight) => { const node = resizableNode(700) render() fireEvent.click(screen.getByRole('button', { name: `add ${type} track` })) expect(node.setSize).toHaveBeenLastCalledWith([800, expectedHeight]) }) it('shrinks the node when deleting an extra media track', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio-extra', name: 'Audio 0', type: 'audio', color: 'test-color', muted: false, locked: false, segments: [], }) const node = resizableNode(764) render() fireEvent.click(screen.getByRole('button', { name: 'delete extra track' })) expect(node.setSize).toHaveBeenLastCalledWith([800, 700]) }) it.each(['video', 'audio', 'subtitle'] as const)('rejects a fourth %s track', (type) => { const data = createDefaultTrackData() const existingCount = data.tracks.filter((track) => track.type === type).length for (let index = existingCount; index < 3; index += 1) { data.tracks.push({ id: `${type}-${index}`, name: `${type} ${index}`, type, color: 'test-color', muted: false, locked: false, segments: [], }) } const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: `add ${type} track` })) expect(onChange).not.toHaveBeenCalled() }) it('replaces audio without extending its existing timeline range', async () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [{ id: 'audio-segment', start_frame: 24, end_frame: 120, color: 'var(--highlight)', content: { media_type: 'audio', source_type: 'output', file_path: 'original.wav', duration: 4, muted: true, volume_db: -6, }, }], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'replace audio segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.at(-1)?.segments[0]).toMatchObject({ start_frame: 24, end_frame: 72, origin_start_frame: 24, content: { source_type: 'input', file_path: 'replacement.wav', duration: 2, muted: true, volume_db: -6, }, }) }) it('preserves the audio clip range when replacement metadata cannot be read', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.mocked(loadBrowserAudioMetadata).mockRejectedValueOnce(new Error('metadata unavailable')) const data = createDefaultTrackData() data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [{ id: 'audio-segment', start_frame: 24, end_frame: 120, color: 'var(--highlight)', content: { media_type: 'audio', source_type: 'input', file_path: 'original.wav' }, }], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'replace audio segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) expect((onChange.mock.lastCall?.[0] as TrackData).tracks.at(-1)?.segments[0]).toMatchObject({ start_frame: 24, end_frame: 120, }) expect((onChange.mock.lastCall?.[0] as TrackData).tracks.at(-1)?.segments[0].content.duration).toBeUndefined() expect(consoleError).toHaveBeenCalledWith( '[MultiTrackWidget] failed to read replacement audio metadata:', expect.any(Error), ) consoleError.mockRestore() }) it('solos a video track by muting other video and audio tracks, then restores them when solo is cleared', () => { const data = createDefaultTrackData() data.tracks[1].id = 'video-main' data.tracks[1].muted = false data.tracks[1].solo = false data.tracks.push( { id: 'video-secondary', name: 'Video 1', type: 'video', color: 'var(--secondary)', muted: false, solo: false, locked: false, segments: [], }, { id: 'audio-main', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, solo: false, locked: false, segments: [], }, ) const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'toggle first video solo' })) const soloed = onChange.mock.lastCall?.[0] as TrackData expect(soloed.tracks.find((track) => track.id === 'video-main')).toMatchObject({ muted: false, solo: true }) expect(soloed.tracks.find((track) => track.id === 'video-secondary')).toMatchObject({ muted: true, solo: false }) expect(soloed.tracks.find((track) => track.id === 'audio-main')).toMatchObject({ muted: true, solo: false }) view.rerender() fireEvent.click(screen.getByRole('button', { name: 'toggle first video solo' })) const restored = onChange.mock.lastCall?.[0] as TrackData expect(restored.tracks.find((track) => track.id === 'video-main')).toMatchObject({ muted: false, solo: false }) expect(restored.tracks.find((track) => track.id === 'video-secondary')).toMatchObject({ muted: false, solo: false }) expect(restored.tracks.find((track) => track.id === 'audio-main')).toMatchObject({ muted: false, solo: false }) }) it('solos an audio track by muting video tracks', () => { const data = createDefaultTrackData() data.tracks[1].id = 'video-main' data.tracks[1].muted = false data.tracks[1].solo = false data.tracks.push({ id: 'audio-main', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, solo: false, locked: false, segments: [], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'toggle first audio solo' })) const soloed = onChange.mock.lastCall?.[0] as TrackData expect(soloed.tracks.find((track) => track.id === 'audio-main')).toMatchObject({ muted: false, solo: true }) expect(soloed.tracks.find((track) => track.id === 'video-main')).toMatchObject({ muted: true, solo: false }) }) it('keeps matching task ranges aligned when inserting a video before existing segments', async () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-existing', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-existing', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 / data.frame_rate }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'insert video segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [0, 24], [24, 34], ]) }) it('merges an asynchronously added video into the latest widget value', async () => { let resolveMetadata: ((metadata: { duration: number, width: number, height: number }) => void) | undefined vi.mocked(loadBrowserVideoMetadata).mockImplementation(() => new Promise((resolve) => { resolveMetadata = resolve })) const initial = createDefaultTrackData() const latest: TrackData = { ...initial, tracks: [...initial.tracks, { id: 'audio-added-while-loading', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [], }] } const onChange = vi.fn() const props = widgetProps() const view = render() fireEvent.click(screen.getByRole('button', { name: 'insert video segment' })) view.rerender() resolveMetadata?.({ duration: 1, width: 1920, height: 1080 }) await waitFor(() => expect(onChange).toHaveBeenCalled()) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.some((track) => track.id === 'audio-added-while-loading')).toBe(true) expect(updated.tracks[1].segments).toHaveLength(1) }) it('provides the ComfyUI graph context to audio tracks', () => { const props = widgetProps() props.node = { inputs: [{ name: 'audio', type: 'AUDIO', link: 7 }], } as ReactWidgetProps['node'] props.app = { ...props.app, graph: { links: { 7: { origin_id: 3, origin_slot: 0 } }, getNodeById: () => ({ type: 'LoadAudio', outputs: [{ shape: 0 }], widgets_values: ['voice.wav'], }), }, } as unknown as ReactWidgetProps['app'] render() expect(screen.getByTestId('audio-slot-context').textContent).toBe('connected') }) it('uses the connected audio filename to load slot metadata and preview the segment', async () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'add audio slot segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) expect((onChange.mock.lastCall?.[0] as TrackData).tracks.at(-1)?.segments[0]).toMatchObject({ end_frame: 48, content: { source_type: 'slot', slot_name: 'audio', file_name: 'audio', url: '/view?filename=voice.wav&type=input&subfolder=', duration: 2, }, }) }) it('uses the upstream LoadVideo file to read a video slot duration', async () => { vi.mocked(loadBrowserVideoMetadata).mockResolvedValueOnce({ duration: 3.5, width: 1920, height: 1080 }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'add load video slot segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) expect(loadBrowserVideoMetadata).toHaveBeenCalledWith('/view?filename=source.mp4&type=input&subfolder=clips') expect((onChange.mock.lastCall?.[0] as TrackData).tracks[1].segments[0]).toMatchObject({ end_frame: 84, content: { source_type: 'slot', slot_name: 'video', url: '/view?filename=source.mp4&type=input&subfolder=clips', duration: 3.5, }, }) }) it.each(['video', 'audio'] as const)( 'allows a %s slot without readable metadata to extend beyond its initial range', async (trackType) => { const data = createDefaultTrackData() if (trackType === 'audio') { data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, locked: false, segments: [], }) } const onChange = vi.fn() const props = { ...widgetProps(), value: data, onChange } const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: trackType === 'video' ? 'add video slot segment' : 'add audio slot without preview', })) await waitFor(() => expect(onChange).toHaveBeenCalled()) const added = onChange.mock.lastCall?.[0] as TrackData const segment = added.tracks.find((track) => track.type === trackType)?.segments[0] expect(segment).toMatchObject({ start_frame: 0, end_frame: 5 * added.frame_rate, content: { source_type: 'slot', slot_name: trackType }, }) expect(segment?.content.duration).toBeUndefined() rerender() fireEvent.click(screen.getByRole('button', { name: `extend ${trackType} end` })) expect((onChange.mock.lastCall?.[0] as TrackData).tracks .find((track) => track.type === trackType)?.segments[0].end_frame).toBe(6 * added.frame_rate) }, ) it('does not treat an unknown video duration as a one-second source limit', async () => { vi.mocked(loadBrowserVideoMetadata).mockResolvedValueOnce({ duration: 0, width: 1920, height: 1080 }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'insert video segment' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) const segment = (onChange.mock.lastCall?.[0] as TrackData).tracks[1].segments[0] expect(segment.end_frame - segment.start_frame).toBe(120) expect(segment.content.duration).toBeUndefined() }) it('can extend a saved video slot with the old one-second fallback duration', () => { const data = createDefaultTrackData() data.tracks[1].segments = [{ id: 'saved-video-slot', start_frame: 0, end_frame: 24, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'slot', slot_name: 'video', duration: 1 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'extend video end' })) expect((onChange.mock.lastCall?.[0] as TrackData).tracks[1].segments[0].end_frame).toBe(48) }) it('applies selected subtitle style changes to every segment on the same subtitle track', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 1', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [ { id: 'subtitle-a', start_frame: 0, end_frame: 24, color: '#9D4937', content: { media_type: 'subtitle', text: 'First line', subtitle_style: { font_size: 12, color: '#ffffff', outline_color: '#000000', background_color: 'rgba(0, 0, 0, 0)', background_opacity: 0.7, x: 0.15, y: 0.8, width: 0.7, }, }, }, { id: 'subtitle-b', start_frame: 24, end_frame: 48, color: '#9D4937', content: { media_type: 'subtitle', text: 'Second line', subtitle_style: { font_size: 10, color: '#00ff00', background_color: 'transparent', background_opacity: 0.7, x: 0.3, y: 0.7, width: 0.5, }, }, }, ], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'select subtitle segment' })) fireEvent.click(screen.getByRole('button', { name: 'update selected subtitle style' })) const updated = onChange.mock.lastCall?.[0] as TrackData const subtitleTrack = updated.tracks.find((track) => track.id === 'subtitle-track') expect(subtitleTrack?.segments.map((segment) => segment.content.subtitle_style)).toEqual([ { font_size: 18, color: '#ff0000', outline_color: '#000000', background_color: 'rgba(0, 0, 0, 0.5)', background_opacity: 0.7, x: 0.2, y: 0.75, width: 0.6, }, { font_size: 18, color: '#ff0000', outline_color: '#000000', background_color: 'rgba(0, 0, 0, 0.5)', background_opacity: 0.7, x: 0.2, y: 0.75, width: 0.6, }, ]) expect(subtitleTrack?.segments.map((segment) => segment.content.text)).toEqual(['First line', 'Second line']) }) it('adds a five-second default subtitle segment to a subtitle track', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 1', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'add subtitle segment' })) const updated = onChange.mock.lastCall?.[0] as TrackData const segment = updated.tracks.find((track) => track.id === 'subtitle-track')?.segments[0] expect(segment).toMatchObject({ start_frame: 0, end_frame: 120, color: '#9D4937', content: { media_type: 'subtitle', text: '默认文字', subtitle_style: { font_size: 12, color: '#ffffff', outline_color: '#000000', background_color: 'rgba(0, 0, 0, 0)', background_opacity: 0.7, x: 0.125, y: 0.8, width: 0.75, }, }, }) }) it('imports SRT cues into the dropped time range and preserves subtitles outside it', () => { const data = createDefaultTrackData() data.total_length = 240 data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 1', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [ { id: 'subtitle-before', start_frame: 0, end_frame: 24, color: '#9D4937', content: { media_type: 'subtitle', text: 'Before' }, }, { id: 'subtitle-overlap', start_frame: 48, end_frame: 72, color: '#9D4937', content: { media_type: 'subtitle', text: 'Replace me' }, }, { id: 'subtitle-after', start_frame: 200, end_frame: 220, color: '#9D4937', content: { media_type: 'subtitle', text: 'After' }, }, ], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'import subtitle file' })) const updated = onChange.mock.lastCall?.[0] as TrackData const segments = updated.tracks.find((track) => track.id === 'subtitle-track')?.segments ?? [] expect(segments.map((segment) => ({ start: segment.start_frame, end: segment.end_frame, text: segment.content.text, }))).toEqual([ { start: 0, end: 24, text: 'Before' }, { start: 48, end: 72, text: 'First imported' }, { start: 96, end: 120, text: 'Second imported' }, { start: 200, end: 220, text: 'After' }, ]) }) it('replaces old subtitles across the full extrema of overlapping imported cues', () => { const data = createDefaultTrackData() data.total_length = 360 data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 1', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [ { id: 'subtitle-overlap-tail', start_frame: 160, end_frame: 180, color: '#9D4937', content: { media_type: 'subtitle', text: 'Replace tail' }, }, { id: 'subtitle-after', start_frame: 300, end_frame: 320, color: '#9D4937', content: { media_type: 'subtitle', text: 'After' }, }, ], }) const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'import overlapping subtitle file' })) const updated = onChange.mock.lastCall?.[0] as TrackData const segments = updated.tracks.find((track) => track.id === 'subtitle-track')?.segments ?? [] expect(segments.map((segment) => segment.content.text)).toEqual([ 'Long imported', 'Short imported', 'After', ]) }) it('covers the whole widget while smart split is pending and clears it on success', async () => { const data = createDefaultTrackData() data.total_length = 240 data.tracks[1].segments = [{ id: 'video', start_frame: 0, end_frame: 240, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] const onChange = vi.fn() let resolveFetch: ((response: Response) => void) | undefined const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => new Promise((resolve) => { resolveFetch = resolve })) vi.stubGlobal('fetch', fetchMock) render() fireEvent.click(screen.getByRole('button', { name: 'smart split video' })) expect(await screen.findByTestId('smart-split-overlay')).not.toBeNull() expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ fps: 24 }) resolveFetch?.({ ok: true, status: 200, json: async () => ({ ranges: [[0, 120], [120, 240]], }), } as Response) await waitFor(() => expect(screen.queryByTestId('smart-split-overlay')).toBeNull()) expect(onChange).toHaveBeenCalledOnce() vi.unstubAllGlobals() }) it('covers the widget while recognizing subtitles and appends a subtitle track', async () => { const data = createDefaultTrackData() data.total_length = 240 data.tracks[1].segments = [{ id: 'video', start_frame: 24, end_frame: 120, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] const onChange = vi.fn() let resolveFetch: ((response: Response) => void) | undefined const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => new Promise((resolve) => { resolveFetch = resolve })) vi.stubGlobal('fetch', fetchMock) render() fireEvent.click(screen.getByRole('button', { name: 'recognize video subtitles' })) expect(await screen.findByTestId('subtitle-recognition-overlay')).not.toBeNull() expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ media_type: 'video', fps: 24, }) resolveFetch?.({ ok: true, status: 200, json: async () => ({ segments: [{ start: 0, end: 1, text: 'Hello' }], }), } as Response) await waitFor(() => expect(screen.queryByTestId('subtitle-recognition-overlay')).toBeNull()) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.find((track) => track.type === 'subtitle')?.segments[0]).toMatchObject({ start_frame: 24, end_frame: 48, color: '#9D4937', content: { media_type: 'subtitle', text: 'Hello', subtitle_style: expect.objectContaining({ font_size: 12 }), }, }) vi.unstubAllGlobals() }) it('generates selected subtitle speech and adds the output to an audio track', async () => { const data = createDefaultTrackData() data.total_length = 120 data.tracks.push({ id: 'subtitle-track', name: 'Subtitle 0', type: 'subtitle', color: '#9D4937', muted: false, locked: false, segments: [{ id: 'subtitle', start_frame: 24, end_frame: 48, color: '#9D4937', content: { media_type: 'subtitle', text: 'Hello 123' }, }], }) const onChange = vi.fn() const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ file_path: 'easy_media/Hello.wav', source_type: 'output', absolute_path: '/tmp/output/easy_media/Hello.wav', message: 'Audio generated and saved to /tmp/output/easy_media/Hello.wav', duration: 1, }), }) vi.stubGlobal('fetch', fetchMock) render() fireEvent.click(screen.getByRole('button', { name: 'select subtitle segment' })) fireEvent.click(screen.getByRole('button', { name: 'generate subtitle speech' })) await waitFor(() => expect(onChange).toHaveBeenCalled()) expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ text: 'Hello 123', model: 'VoxCPM2', prompt: 'calm', cfg: 2.4, steps: 13, reference_audio_path: 'voice.wav', reference_audio_source_type: 'input', }) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.find((track) => track.type === 'audio')?.segments[0]).toMatchObject({ start_frame: 24, end_frame: 48, content: { media_type: 'audio', source_type: 'output', file_path: 'easy_media/Hello.wav', }, }) vi.unstubAllGlobals() }) it('shows missing model actions and downloads the requested model', async () => { const data = createDefaultTrackData() data.total_length = 240 data.tracks[1].segments = [{ id: 'video', start_frame: 0, end_frame: 240, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] const model = { name: 'omnishotcut', display_name: 'OmniShotCut', filename: 'OmniShotCut_ckpt.pth', directory: '/ComfyUI/models/checkpoints', path: '/ComfyUI/models/checkpoints/OmniShotCut_ckpt.pth', url: 'https://huggingface.co/uva-cv-lab/OmniShotCut/resolve/main/OmniShotCut_ckpt.pth', } const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: false, status: 428, json: async () => ({ error: 'OmniShotCut model is not installed.', model_missing: model, }), } as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true, model }), } as Response) vi.stubGlobal('fetch', fetchMock) render() fireEvent.click(screen.getByRole('button', { name: 'smart split video' })) expect(await screen.findByTestId('missing-model-overlay')).not.toBeNull() expect(screen.getByText('OmniShotCut is missing')).not.toBeNull() expect(screen.getByText(/installed under checkpoints/)).not.toBeNull() expect(screen.getByText('/ComfyUI/models/checkpoints/OmniShotCut_ckpt.pth')).not.toBeNull() expect(screen.queryByRole('button', { name: 'Exit' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Auto download' })) await waitFor(() => expect(screen.queryByTestId('missing-model-overlay')).toBeNull()) expect(fetchMock).toHaveBeenNthCalledWith(2, '/easy-media/models/download', expect.objectContaining({ method: 'POST', body: JSON.stringify({ model_name: 'omnishotcut' }), })) vi.unstubAllGlobals() }) it('opens the source URL for manual model download', async () => { const data = createDefaultTrackData() data.tracks[1].segments = [{ id: 'video', start_frame: 0, end_frame: 240, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] const model = { name: 'omnishotcut', display_name: 'OmniShotCut', filename: 'OmniShotCut_ckpt.pth', directory: '/ComfyUI/models/checkpoints', path: '/ComfyUI/models/checkpoints/OmniShotCut_ckpt.pth', url: 'https://huggingface.co/uva-cv-lab/OmniShotCut/resolve/main/OmniShotCut_ckpt.pth', } vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 428, json: async () => ({ error: 'missing', model_missing: model }), })) const openMock = vi.fn() vi.stubGlobal('open', openMock) render() fireEvent.click(screen.getByRole('button', { name: 'smart split video' })) fireEvent.click(await screen.findByRole('button', { name: 'Manual download' })) expect(openMock).toHaveBeenCalledWith(model.url, '_blank', 'noopener,noreferrer') vi.unstubAllGlobals() }) it('does not request task-only smart split when no task range matches', () => { const data = createDefaultTrackData() data.tracks[1].segments = [{ id: 'video', start_frame: 0, end_frame: 240, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'clip.mp4' }, }] const toastAdd = vi.fn() const props = widgetProps() props.app = { ...props.app, extensionManager: { toast: { add: toastAdd } }, } as unknown as ReactWidgetProps['app'] const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) render() fireEvent.click(screen.getByRole('button', { name: 'smart split tasks' })) expect(fetchMock).not.toHaveBeenCalled() expect(toastAdd).toHaveBeenCalledWith(expect.objectContaining({ severity: 'warn' })) vi.unstubAllGlobals() }) it('cuts all active track segments at the current time when no segment is selected', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-active', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'cut current time' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments).toHaveLength(2) expect(updated.tracks[0].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([[0, 5], [5, 10]]) expect(updated.tracks[1].segments).toHaveLength(2) expect(updated.tracks[1].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([[0, 5], [5, 10]]) }) it('cuts only selected segments at the current time', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-active', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'select video segment' })) fireEvent.click(screen.getByRole('button', { name: 'cut current time' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments).toHaveLength(1) expect(updated.tracks[1].segments).toHaveLength(2) expect(updated.tracks[1].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([[0, 5], [5, 10]]) }) it.each([ ['trim left current time', 'start_frame', 5], ['trim right current time', 'end_frame', 5], ] as const)('trims all active track segments from %s when no segment is selected', (buttonName, frameKey, expectedFrame) => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-active', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const audioTrack = data.tracks.find((track) => track.type === 'audio') if (audioTrack) { audioTrack.segments = [{ id: 'audio-active', start_frame: 0, end_frame: 10, color: audioTrack.color, content: { media_type: 'audio', duration: 10 }, }] } const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) const trimButton = screen.getByRole('button', { name: buttonName }) expect((trimButton as HTMLButtonElement).disabled).toBe(false) fireEvent.click(trimButton) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments[0][frameKey]).toBe(expectedFrame) expect(updated.tracks[1].segments[0][frameKey]).toBe(expectedFrame) const updatedAudioTrack = updated.tracks.find((track) => track.type === 'audio') if (updatedAudioTrack) expect(updatedAudioTrack.segments[0][frameKey]).toBe(expectedFrame) }) it.each([ 'video', 'audio', ] as const)('keeps the playhead-to-end range when trimming the left side of a non-zero %s segment', (trackType) => { const data = createDefaultTrackData() const track = data.tracks.find((item) => item.type === trackType) ?? { id: 'audio-track', name: 'Audio', type: 'audio' as const, color: 'var(--highlight)', muted: false, locked: false, segments: [], } if (!data.tracks.includes(track)) data.tracks.push(track) track.segments = [{ id: `${trackType}-offset`, start_frame: 2, end_frame: 10, color: track.color, content: { media_type: trackType, duration: 8 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'trim left current time' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.find((item) => item.type === trackType)?.segments[0]).toMatchObject({ start_frame: 5, end_frame: 10, origin_start_frame: 2, }) }) it.each([ 'video', 'audio', ] as const)('keeps the start-to-playhead range when trimming the right side of a non-zero %s segment', (trackType) => { const data = createDefaultTrackData() const track = data.tracks.find((item) => item.type === trackType) ?? { id: 'audio-track', name: 'Audio', type: 'audio' as const, color: 'var(--highlight)', muted: false, locked: false, segments: [], } if (!data.tracks.includes(track)) data.tracks.push(track) track.segments = [{ id: `${trackType}-offset`, start_frame: 2, end_frame: 10, color: track.color, content: { media_type: trackType, duration: 8 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'trim right current time' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.find((item) => item.type === trackType)?.segments[0]).toMatchObject({ start_frame: 2, end_frame: 5 }) }) it('trims a selected video segment and keeps the matching task aligned', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-active', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByTestId('multitrack-ruler')) fireEvent.click(screen.getByRole('button', { name: 'select video segment' })) fireEvent.click(screen.getByRole('button', { name: 'trim left current time' })) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks[0].segments[0].start_frame).toBe(5) expect(updated.tracks[1].segments[0].start_frame).toBe(5) }) it('clears selected segment ids after deleting selected segments', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-active', start_frame: 0, end_frame: 10, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-active', start_frame: 0, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'video', duration: 10 }, }] const onChange = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'select video and task' })) expect(screen.getByTestId('selected-segment-count').textContent).toBe('2') fireEvent.click(screen.getByRole('button', { name: 'delete selected' })) expect(screen.getByTestId('selected-segment-count').textContent).toBe('0') expect(screen.getByRole('button', { name: 'delete selected' }).disabled).toBe(true) const updated = onChange.mock.lastCall?.[0] as TrackData expect(updated.tracks.flatMap((track) => track.segments.map((segment) => segment.id))).not.toContain('video-active') }) it('keeps a subtitle selected when its layout changes before the click event', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'subtitle-track', type: 'subtitle', name: 'Subtitle', color: '#ffffff', muted: false, locked: false, segments: [{ id: 'subtitle-active', start_frame: 0, end_frame: 10, color: '#ffffff', content: { media_type: 'subtitle', text: 'Hello' }, }], }) render() const segment = screen.getByRole('button', { name: 'select subtitle segment before click' }) fireEvent.mouseDown(segment, { button: 0 }) expect(screen.getByTestId('selected-segment-count').textContent).toBe('1') fireEvent.click(segment) expect(screen.getByTestId('selected-segment-count').textContent).toBe('1') }) })