import { describe, expect, it } from 'vitest' import { addDefaultTaskSegmentIfRangeEmpty, applyCombinedTaskTexts, calculateTotalLength, calculateReplacementAudioEndFrame, cloneMultiTrackSegment, collectMultiTrackPreviewResolutionInput, createDefaultTrackData, createMultiTrackAudioContent, createMultiTrackVideoContent, deleteSegmentsWithLinkedTasks, deleteSegmentWithLinkedTasks, distributeMultiTrackSegmentsEvenly, formatMultiTrackDurationTimecode, formatMultiTrackTime, getActivePreviewVideoSegment, getActivePreviewAudioSources, getMultiTrackTaskModeLabel, getSelectedMultiTrackSegment, frameToSeconds, getSegmentDragPlaceholder, getSegmentDragPreviewSegments, MULTITRACK_TASK_MODES, mergeSelectedTaskSegments, moveSelectedSegments, moveSegmentBetweenCompatibleTracks, multiTrackDbToLinearGain, normalizeTrackData, parseMultiTrackDurationTimecode, parseMultiTrackPreviewResolution, remapTrackDataFrameRate, resizeTaskSegmentEnd, secondsToFrame, setMultiTrackAudioTrackLock, splitMultiTrackSegmentByFrames, snapMultiTrackMoveStartTime, snapMultiTrackResizeTime, snapTimeToFrame, updateMultiTrackSegmentContent, updateMultiTrackSegmentDuration, } from '@/lib/multitrack-utils' describe('multitrack utilities', () => { it('merges consecutive selected task segments using the first segment with prompt or active images', () => { const data = createDefaultTrackData() const taskTrack = data.tracks[0] taskTrack.segments = [ { id: 'empty', start_frame: 0, end_frame: 24, color: taskTrack.color, content: { media_type: 'none', user_prompt: '', images: [{ id: 'muted', muted: true }] }, }, { id: 'content', start_frame: 24, end_frame: 48, color: taskTrack.color, content: { media_type: 'none', user_prompt: 'Keep me', images: [{ id: 'active' }] }, }, { id: 'later', start_frame: 48, end_frame: 72, color: taskTrack.color, content: { media_type: 'none', user_prompt: 'Later prompt', images: [] }, }, ] const result = mergeSelectedTaskSegments( data.tracks, new Set(['empty', 'content', 'later']), ) expect(result?.mergedSegmentId).toBe('empty') expect(result?.tracks[0].segments).toEqual([ expect.objectContaining({ id: 'empty', start_frame: 0, end_frame: 72, content: expect.objectContaining({ user_prompt: 'Keep me', images: [{ id: 'active' }] }), }), ]) }) it('does not merge non-consecutive task selections or selections across tracks', () => { const data = createDefaultTrackData() const taskTrack = data.tracks[0] taskTrack.segments = ['first', 'middle', 'last'].map((id, index) => ({ id, start_frame: index * 24, end_frame: (index + 1) * 24, color: taskTrack.color, content: { media_type: 'none' as const }, })) expect(mergeSelectedTaskSegments(data.tracks, new Set(['first', 'last']))).toBeNull() }) it('keeps video and audio locks independent while allowing one per type', () => { const data = createDefaultTrackData() const audioTrack = (id: string, audioLocked: boolean) => ({ id, name: id, type: 'audio' as const, color: 'var(--multitrack-audio-bg)', muted: false, locked: false, audio_locked: audioLocked, segments: [], }) data.tracks.push(audioTrack('audio-a', true), audioTrack('audio-b', false)) const videoLocked = setMultiTrackAudioTrackLock(data, data.tracks[1].id, true) expect(videoLocked.tracks.filter((track) => track.type === 'video' || track.type === 'audio').map((track) => track.audio_locked)).toEqual([true, true, false]) const updated = setMultiTrackAudioTrackLock(videoLocked, 'audio-b', true) expect(updated.tracks.filter((track) => track.type === 'video' || track.type === 'audio').map((track) => track.audio_locked)).toEqual([true, false, true]) expect(setMultiTrackAudioTrackLock(data, 'missing', true)).toBe(data) }) it('normalizes multiple locks to the first locked track of each media type', () => { const data = createDefaultTrackData() data.tracks[1].audio_locked = true data.tracks.push({ id: 'audio', name: 'Audio', type: 'audio', color: 'var(--multitrack-audio-bg)', muted: false, locked: false, audio_locked: true, segments: [], }) const normalized = normalizeTrackData(data) expect(normalized.tracks.filter((track) => track.type === 'video' || track.type === 'audio').map((track) => track.audio_locked)).toEqual([true, true]) }) it('migrates a legacy segment lock to its audio track', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio', name: 'Audio', type: 'audio', color: 'var(--multitrack-audio-bg)', muted: false, locked: false, segments: [{ id: 'clip', start_frame: 0, end_frame: 10, color: 'var(--multitrack-audio-bg)', content: { media_type: 'audio', audio_locked: true } as never, }], }) const normalized = normalizeTrackData(data) const track = normalized.tracks.find((item) => item.id === 'audio')! expect(track.audio_locked).toBe(true) expect(track.segments[0].content).not.toHaveProperty('audio_locked') }) it('migrates legacy speaker clips and keeps only the first shared reference', () => { const data = createDefaultTrackData() data.tracks.push({ id: 'audio', name: 'Audio', type: 'audio', color: 'var(--multitrack-audio-bg)', muted: false, locked: false, segments: [ { id: 'first', start_frame: 0, end_frame: 24, color: 'var(--multitrack-audio-bg)', content: { media_type: 'audio', speaker_reference: true } }, { id: 'second', start_frame: 24, end_frame: 48, color: 'var(--multitrack-audio-bg)', content: { media_type: 'audio', speaker_reference: true } }, ], }) const normalized = normalizeTrackData(data) const track = normalized.tracks.find((item) => item.id === 'audio')! expect(track.segments.map((segment) => segment.content.shared_reference)).toEqual([true, false]) expect(track.segments.every((segment) => segment.content.speaker_reference === undefined)).toBe(true) }) it('prefixes shared images in every task and reuses matching paths under the nine-image limit', () => { const data = createDefaultTrackData() const taskTrack = data.tracks[0] taskTrack.segments = [ { id: 'first', start_frame: 0, end_frame: 24, color: taskTrack.color, content: { media_type: 'none', images: [ { id: 'local-a', source_type: 'input', file_path: 'local-a.png' }, { id: 'shared', source_type: 'input', file_path: 'shared.png', shared_reference: true }, ] }, }, { id: 'second', start_frame: 24, end_frame: 48, color: taskTrack.color, content: { media_type: 'none', images: [ { id: 'same-path', source_type: 'input', file_path: 'shared.png' }, ...Array.from({ length: 8 }, (_, index) => ({ id: `local-${index}`, source_type: 'input' as const, file_path: `local-${index}.png`, })), ] }, }, ] const normalized = normalizeTrackData(data) const normalizedTasks = normalized.tracks[0].segments expect(normalizedTasks[0].content.images?.map((image) => image.file_path)).toEqual([ 'shared.png', 'local-a.png', ]) expect(normalizedTasks[1].content.images).toHaveLength(9) expect(normalizedTasks[1].content.images?.[0]).toMatchObject({ id: 'same-path', file_path: 'shared.png', shared_reference: true, }) }) it('applies combined prompt text to each task selected A/B variant', () => { const data = createDefaultTrackData() const first = { id: 'task-a', start_frame: 0, end_frame: 60, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const, user_prompt: 'A1', }, } const second = { ...first, id: 'task-b', start_frame: 60, end_frame: 120, content: { ...first.content, user_prompt: 'A2', user_prompt_b: 'B2', user_prompt_variant: 'b' as const, }, } const updated = applyCombinedTaskTexts( ['Updated A', 'Updated B'], [first, second], 120, first.color, ) expect(updated[0].content).toMatchObject({ user_prompt: 'Updated A' }) expect(updated[1].content).toMatchObject({ user_prompt: 'A2', user_prompt_b: 'Updated B', user_prompt_variant: 'b', }) }) it('inherits task settings when combined text creates another task segment', () => { const data = createDefaultTrackData() const first = { id: 'task-a', start_frame: 0, end_frame: 60, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'ref' as const, system_prompt: 'Custom prompt', }, } const updated = applyCombinedTaskTexts(['First', 'Second'], [first], 120, first.color) expect(updated[1].content).toMatchObject({ task_mode: 'ref', system_prompt: 'Custom prompt', continuity_mode: 'shot', user_prompt: 'Second', }) }) it('uses a short segment range instead of padding the timeline to five seconds', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'short-task', start_frame: 0, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] expect(calculateTotalLength(data.tracks, 24)).toBe(48) }) it('keeps the five-second default for an empty timeline', () => { expect(calculateTotalLength(createDefaultTrackData().tracks, 24)).toBe(120) }) it('crops a longer replacement audio to the current clip duration', () => { expect(calculateReplacementAudioEndFrame({ start_frame: 24, end_frame: 120 }, 10, 24)).toBe(120) }) it('shrinks an audio clip when its replacement is shorter', () => { expect(calculateReplacementAudioEndFrame({ start_frame: 24, end_frame: 120 }, 2, 24)).toBe(72) }) it('evenly distributes multitrack segments across a half-open frame range', () => { const source = { id: 'source', start_frame: 0, end_frame: 1, color: 'var(--multitrack-task-bg)', content: { media_type: 'none' as const, task_mode: 'default' as const }, } const segments = [ { ...source, id: 'third', start_frame: 8, end_frame: 10 }, { ...source, id: 'first', start_frame: 0, end_frame: 2 }, { ...source, id: 'second', start_frame: 3, end_frame: 7 }, ] expect(distributeMultiTrackSegmentsEvenly(segments, 10).map((segment) => ({ id: segment.id, start_frame: segment.start_frame, end_frame: segment.end_frame, }))).toEqual([ { id: 'first', start_frame: 0, end_frame: 4 }, { id: 'second', start_frame: 4, end_frame: 7 }, { id: 'third', start_frame: 7, end_frame: 10 }, ]) }) it('splits one multitrack segment by target frames and keeps the remainder last', () => { const source = { id: 'source', start_frame: 5, end_frame: 15, color: 'var(--multitrack-task-bg)', content: { media_type: 'none' as const, task_mode: 'default' as const, images: [{ id: 'image', source_type: 'input' as const, file_path: 'image.png' }], }, } const result = splitMultiTrackSegmentByFrames([source], source.id, 3) expect(result).not.toBeNull() expect(result?.segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['source', 5, 8], [result?.splitSegmentIds[1], 8, 11], [result?.splitSegmentIds[2], 11, 14], [result?.splitSegmentIds[3], 14, 15], ]) expect(result?.segments[1].content).toEqual(source.content) expect(result?.segments[1].content).not.toBe(source.content) }) it('deep-clones a segment after its source and shifts following segments', () => { const source = { id: 'source', start_frame: 0, end_frame: 3, color: 'var(--multitrack-task-bg)', content: { media_type: 'none' as const, task_mode: 'default' as const, images: [{ id: 'image', source_type: 'input' as const, file_path: 'image.png' }], }, } const following = { ...source, id: 'following', start_frame: 3, end_frame: 5 } const result = cloneMultiTrackSegment([source, following], source.id) expect(result).not.toBeNull() expect(result?.addedDuration).toBe(3) expect(result?.segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['source', 0, 3], [result?.clonedSegmentId, 3, 6], ['following', 6, 8], ]) expect(result?.segments[1].content).toEqual(source.content) expect(result?.segments[1].content).not.toBe(source.content) }) it('preserves source media offsets when cloning and shifting media segments', () => { const source = { id: 'source', start_frame: 5, end_frame: 8, origin_start_frame: 3, color: 'var(--primary)', content: { media_type: 'video' as const, file_path: 'source.mp4' }, } const following = { ...source, id: 'following', start_frame: 8, end_frame: 10, origin_start_frame: 8, } const result = cloneMultiTrackSegment([source, following], source.id) expect(result?.segments.map((segment) => [ segment.id, segment.start_frame, segment.end_frame, segment.origin_start_frame, ])).toEqual([ ['source', 5, 8, 3], [result?.clonedSegmentId, 8, 11, 6], ['following', 11, 13, 11], ]) }) it('formats and validates minute-second-frame duration timecodes', () => { expect(formatMultiTrackDurationTimecode(65.5, 24)).toBe('01:05:12') expect(parseMultiTrackDurationTimecode('01:05:12', 24)).toBe(65.5) expect(parseMultiTrackDurationTimecode('00:60:00', 24)).toBeNull() expect(parseMultiTrackDurationTimecode('00:01:24', 24)).toBeNull() expect(parseMultiTrackDurationTimecode('0:01:00', 24)).toBeNull() expect(parseMultiTrackDurationTimecode('00:00:00', 24)).toBeNull() }) it('serializes direct video URLs as URL media', () => { expect(createMultiTrackVideoContent('https://example.com/clips/shot.mp4', 'input')).toEqual({ media_type: 'video', source_type: 'url', file_path: undefined, local_path: undefined, url: 'https://example.com/clips/shot.mp4', file_name: 'shot.mp4', }) }) it('serializes connected audio slots as slot media', () => { expect(createMultiTrackAudioContent('__slot__:audio2', 'input')).toMatchObject({ media_type: 'audio', source_type: 'slot', slot_name: 'audio2', file_name: 'audio2', volume_db: 0, }) }) it('serializes connected video slots as slot media', () => { expect(createMultiTrackVideoContent('__slot__:video2', 'input')).toMatchObject({ media_type: 'video', source_type: 'slot', slot_name: 'video2', file_name: 'video2', }) }) it('creates default data with task and video tracks', () => { const data = createDefaultTrackData() expect(data.frame_rate).toBe(24) expect(data.total_length).toBe(120) expect(data.muted).toBe(false) expect(data.volume_db).toBe(0) expect(data.task_overview).toBe(false) expect(data.tracks.every((track) => track.solo === false && track.volume_db === 0)).toBe(true) expect(data.tracks.map((track) => track.type)).toEqual(['task', 'video']) expect(data.tracks.map((track) => track.name)).toEqual(['Task 0', 'Video 0']) expect(data.tracks[0].task_mode).toBe('default') expect(data.task_markers).toEqual([]) }) it('normalizes, sorts, deduplicates, and remaps task markers', () => { 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 normalized = normalizeTrackData({ ...data, task_markers: [ { id: 'late', frame: 96 }, { id: 'early', frame: 24 }, { id: 'duplicate', frame: 24 }, { id: 'end', frame: 120 }, { id: 'outside', frame: 121 }, ], }) expect(normalized.task_markers).toEqual([ { id: 'early', frame: 24 }, { id: 'late', frame: 96 }, { id: 'end', frame: 120 }, ]) expect(remapTrackDataFrameRate(normalized, 48).task_markers).toEqual([ { id: 'early', frame: 48 }, { id: 'late', frame: 192 }, { id: 'end', frame: 240 }, ]) }) it('uses only volume_db and muted for normalized audio settings', () => { const normalized = normalizeTrackData({ muted: false, volume: 0.5, volume_db: -12, tracks: [{ id: 'audio', name: 'Audio 1', type: 'audio', color: 'var(--multitrack-audio-bg)', muted: false, locked: false, segments: [{ id: 'audio-segment', start_frame: 0, end_frame: 24, color: 'var(--multitrack-audio-bg)', content: { media_type: 'audio', volume: 0, volume_db: -4, muted: false }, }], }], total_length: 24, frame_rate: 24, } as unknown as Parameters[0]) expect(normalized.volume_db).toBe(-12) expect('volume' in normalized).toBe(false) expect(normalized.tracks[0]).toMatchObject({ name: 'Audio 0', solo: false, volume_db: 0 }) expect(normalized.tracks[0].segments[0].content).toMatchObject({ muted: false, volume_db: -4 }) expect('volume' in normalized.tracks[0].segments[0].content).toBe(false) expect(multiTrackDbToLinearGain(6)).toBeCloseTo(1.995, 2) }) it('maintains task modes separately from media track types', () => { expect(MULTITRACK_TASK_MODES).toEqual(['default', 'ref', 'edit', 'l2v']) expect(getMultiTrackTaskModeLabel('default', (key) => key)).toBe('multitrackTaskModes.default') expect(getMultiTrackTaskModeLabel('l2v', (key) => key)).toBe('multitrackTaskModes.l2v') expect(getMultiTrackTaskModeLabel('ref', (key) => key)).toBe('multitrackTaskModes.ref') expect(getMultiTrackTaskModeLabel('edit', (key) => key)).toBe('multitrackTaskModes.edit') }) it('migrates legacy segment tracks to task tracks', () => { const normalized = normalizeTrackData({ tracks: [ { id: 'track1', name: 'Segment 1', type: 'segment', color: 'var(--muted)', muted: false, locked: false, segments: [], }, ], total_length: 120, frame_rate: 24, } as unknown as Parameters[0]) expect(normalized).toMatchObject({ muted: false, volume_db: 0 }) expect(normalized.tracks[0]).toMatchObject({ name: 'Task 0', type: 'task', task_mode: 'default', color: 'var(--multitrack-task-bg)', }) }) it('normalizes invalid task segment modes and image lists', () => { const normalized = normalizeTrackData({ tracks: [ { id: 'task', name: 'Task 1', type: 'task', task_mode: 'edit', color: 'var(--multitrack-task-bg)', muted: false, locked: false, segments: [ { id: 'task-segment', start_frame: 0, end_frame: 2, color: 'var(--multitrack-task-bg)', content: { media_type: 'none', task_mode: 'unknown', text: 'Prompt', images: 'not-an-array', }, }, ], }, ], total_length: 120, frame_rate: 24, } as unknown as Parameters[0]) expect(normalized.tracks[0].task_mode).toBe('edit') expect(normalized.tracks[0].segments[0].content).toMatchObject({ task_mode: 'default', continuity_mode: 'shot', ref_image_size: 'match', user_prompt: 'Prompt', images: [], }) expect(normalized.tracks[0].segments[0].content.text).toBeUndefined() }) it('does not migrate legacy second-based multitrack time fields', () => { const normalized = normalizeTrackData({ tracks: [ { id: 'video', name: 'Video 1', type: 'video', color: 'var(--primary)', muted: false, locked: false, segments: [ { id: 'legacy-seconds', start_time: 1, end_time: 2, color: 'var(--primary)', content: { media_type: 'video' }, }, ], }, ], total_duration: 5, frame_rate: 24, } as unknown as Parameters[0]) expect(normalized.total_length).toBe(1) expect(normalized.tracks[0].segments[0]).toMatchObject({ start_frame: 0, end_frame: 1, }) }) it('derives normalized total length from segment ranges instead of stale serialized data', () => { const normalized = normalizeTrackData({ tracks: [ { id: 'task', name: 'Task 0', type: 'task', color: 'var(--multitrack-task-bg)', muted: false, locked: false, segments: [ { id: 'task-1', start_frame: 0, end_frame: 29, color: 'var(--multitrack-task-bg)', content: { media_type: 'none' }, }, { id: 'task-2', start_frame: 29, end_frame: 86, color: 'var(--multitrack-task-bg)', content: { media_type: 'none' }, }, ], }, { id: 'video', name: 'Video 0', type: 'video', color: 'var(--primary)', muted: false, locked: false, segments: [{ id: 'video-1', start_frame: 0, end_frame: 86, color: 'var(--primary)', content: { media_type: 'video' }, }], }, ], total_length: 120, frame_rate: 16, }) expect(normalized.total_length).toBe(86) expect(formatMultiTrackTime(normalized.total_length, { frameRate: normalized.frame_rate, showFrames: true, })).toBe('00:05:06') }) it('finds the active preview video segment for default and selected-video modes', () => { const data = createDefaultTrackData() const videoTrack = { ...data.tracks[1], segments: [ { id: 'first-video', start_frame: 0, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 2, file_name: 'a.mp4' }, }, { id: 'second-video', start_frame: 96, end_frame: 168, origin_start_frame: 96, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 3, file_name: 'b.mp4' }, }, ], } const trackData = { ...data, tracks: [data.tracks[0], videoTrack], total_length: 168 } expect(getActivePreviewVideoSegment(trackData, 24, null)?.segment.id).toBe('first-video') expect(getActivePreviewVideoSegment(trackData, 72, null)).toBeNull() expect(getActivePreviewVideoSegment(trackData, 120, 'second-video')).toMatchObject({ trackId: videoTrack.id, localTime: 1, }) expect(getActivePreviewVideoSegment(trackData, 72, 'second-video')).toBeNull() videoTrack.segments[1].origin_start_frame = 48 expect(getActivePreviewVideoSegment(trackData, 120, 'second-video')).toMatchObject({ localTime: 3, }) expect(getActivePreviewVideoSegment(trackData, trackData.total_length, null)).toMatchObject({ trackId: videoTrack.id, segment: { id: 'second-video' }, localTime: 119 / 24, }) }) it('collects all active audio sources and applies track solo, mute, and dB settings', () => { const data = createDefaultTrackData() data.volume_db = 1 data.tracks[1] = { ...data.tracks[1], id: 'video-track', volume_db: 2, segments: [{ id: 'video-audio', start_frame: 0, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'video.mp4', volume_db: 3 }, }], } data.tracks.push({ id: 'audio-track', name: 'Audio 1', type: 'audio', color: 'var(--multitrack-audio-bg)', muted: false, solo: false, volume_db: -1, locked: false, segments: [{ id: 'audio-segment', start_frame: 0, end_frame: 48, color: 'var(--multitrack-audio-bg)', content: { media_type: 'audio', source_type: 'input', file_path: 'audio.wav', volume_db: -2 }, }], }) expect(getActivePreviewAudioSources(data, 24, null).map((source) => ({ id: source.segment.id, localTime: source.localTime, volumeDb: source.volumeDb, }))).toEqual([ { id: 'video-audio', localTime: 1, volumeDb: 6 }, { id: 'audio-segment', localTime: 1, volumeDb: -2 }, ]) data.tracks[2].solo = true expect(getActivePreviewAudioSources(data, 24, null).map((source) => source.segment.id)).toEqual(['audio-segment']) data.tracks[2].muted = true expect(getActivePreviewAudioSources(data, 24, null)).toEqual([]) }) it('keeps the final audio frame active at the timeline end', () => { const data = createDefaultTrackData() data.total_length = 48 data.tracks[1] = { ...data.tracks[1], segments: [{ id: 'ending-video', start_frame: 0, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video', source_type: 'input', file_path: 'video.mp4' }, }], } expect(getActivePreviewAudioSources(data, data.total_length, null)).toMatchObject([{ segment: { id: 'ending-video' }, localTime: 47 / 24, }]) }) it('parses preview resolution aspect ratios from fixed, custom, and auto modes', () => { expect(parseMultiTrackPreviewResolution({ resolution: '1920 x 1080 (16:9)', resize_method: 'crop', }, null)).toMatchObject({ width: 1920, height: 1080, resizeMethod: 'crop', mode: 'fixed', }) expect(parseMultiTrackPreviewResolution({ resolution: 'width x height (custom)', width: 544, height: 960, resize_method: 'pad', }, null)).toMatchObject({ width: 544, height: 960, resizeMethod: 'pad', mode: 'custom', }) expect(parseMultiTrackPreviewResolution({ resolution: 'width x height (auto)', resize_method: 'stretch', }, { width: 1280, height: 720 })).toMatchObject({ width: 1280, height: 720, resizeMethod: 'stretch', mode: 'auto', }) }) it('collects dynamic combo resolution values from sibling node widgets', () => { const input = collectMultiTrackPreviewResolutionInput({ widgets: [ { name: 'resolution', value: ['width x height (custom)'] }, { name: 'resolution.resize_method', value: ['crop'] }, { name: 'resolution.width', value: [1024] }, { name: 'resolution.height', value: [576] }, ], }) expect(input).toEqual({ resolution: 'width x height (custom)', resize_method: 'crop', width: 1024, height: 576, }) expect(parseMultiTrackPreviewResolution(input, null)).toMatchObject({ width: 1024, height: 576, resizeMethod: 'crop', mode: 'custom', }) }) it('collects and parses megapixel resolution values on the video format grid', () => { const input = collectMultiTrackPreviewResolutionInput({ widgets: [ { name: 'resolution', value: ['width x height (megapixels)'] }, { name: 'resolution.aspect_ratio', value: ['16:9 (Widescreen)'] }, { name: 'resolution.megapixels', value: [1] }, { name: 'format', value: ['Wan'] }, ], }) expect(input).toEqual({ resolution: 'width x height (megapixels)', aspect_ratio: '16:9 (Widescreen)', megapixels: 1, format: 'Wan', }) expect(parseMultiTrackPreviewResolution(input, null)).toMatchObject({ width: 1368, height: 768, mode: 'megapixels', }) }) it('uses backend-style shortest and longest resolution inference from first video metadata', () => { expect(parseMultiTrackPreviewResolution({ resolution: 'width x height (shortest)', resize_method: 'pad', resize_to_pixel: 640, }, { width: 1280, height: 720 })).toMatchObject({ width: 1138, height: 640, mode: 'shortest', }) expect(parseMultiTrackPreviewResolution({ resolution: 'width x height (longest)', resize_method: 'pad', resize_to_pixel: 640, }, { width: 1280, height: 720 })).toMatchObject({ width: 640, height: 360, mode: 'longest', }) }) it('returns selected segment context with its owning track', () => { const data = createDefaultTrackData() const taskSegment = { id: 'task-selected', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'edit' as const }, } const trackData = { ...data, tracks: [ { ...data.tracks[0], segments: [taskSegment] }, data.tracks[1], ], } expect(getSelectedMultiTrackSegment(trackData, 'task-selected')).toMatchObject({ trackId: data.tracks[0].id, trackType: 'task', segment: taskSegment, }) expect(getSelectedMultiTrackSegment(trackData, 'missing')).toBeNull() expect(getSelectedMultiTrackSegment(trackData, null)).toBeNull() }) it('updates selected segment content without changing other segments', () => { const data = createDefaultTrackData() const taskSegment = { id: 'task-selected', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const, user_prompt: '' }, } const videoSegment = { id: 'video-keep', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video' as const, file_name: 'clip.mp4' }, } const trackData = { ...data, tracks: [ { ...data.tracks[0], segments: [taskSegment] }, { ...data.tracks[1], segments: [videoSegment] }, ], } const updated = updateMultiTrackSegmentContent(trackData, 'task-selected', { task_mode: 'ref', user_prompt: 'New prompt', }) expect(updated.tracks[0].segments[0].content).toMatchObject({ task_mode: 'ref', user_prompt: 'New prompt', }) expect(updated.tracks[1].segments[0]).toBe(videoSegment) }) it('updates segment duration on frame boundaries and recalculates total duration', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-matching', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-overlapping', start_frame: 24, end_frame: 36, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] const videoSegment = { id: 'video-duration', start_frame: 24, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 10 }, } const trackData = { ...data, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [videoSegment] }, ], } const updated = updateMultiTrackSegmentDuration(trackData, 'video-duration', 2.03, 24) expect(updated.tracks[1].segments[0]).toMatchObject({ start_frame: 24, end_frame: 73, }) expect(updated.tracks[0].segments.map((segment) => ({ id: segment.id, start_frame: segment.start_frame, end_frame: segment.end_frame, }))).toEqual([ { id: 'task-matching', start_frame: 24, end_frame: 73 }, { id: 'task-overlapping', start_frame: 24, end_frame: 36 }, ]) expect(updated.total_length).toBe(73) }) it('keeps duration edits from overlapping the next segment', () => { const data = createDefaultTrackData() const trackData = { ...data, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 24, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, { id: 'next', start_frame: 60, end_frame: 84, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } const updated = updateMultiTrackSegmentDuration(trackData, 'selected', 10, 24) expect(updated.tracks[1].segments[0].end_frame).toBe(60) }) it('shifts later task segments when resizing an earlier task end', () => { const data = createDefaultTrackData() const segments = [ { id: 'first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'second', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'third', start_frame: 60, end_frame: 84, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, ] expect(resizeTaskSegmentEnd(segments, 'first', 36).map((segment) => [ segment.start_frame, segment.end_frame, ])).toEqual([ [0, 36], [36, 60], [72, 96], ]) }) it('uses the gap after a task segment before shifting later segments', () => { const data = createDefaultTrackData() const segments = [ { id: 'first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'second', start_frame: 48, end_frame: 72, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'third', start_frame: 84, end_frame: 108, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, ] expect(resizeTaskSegmentEnd(segments, 'first', 36).map((segment) => [ segment.start_frame, segment.end_frame, ])).toEqual([ [0, 36], [48, 72], [84, 108], ]) expect(resizeTaskSegmentEnd(segments, 'first', 48).map((segment) => [ segment.start_frame, segment.end_frame, ])).toEqual([ [0, 48], [48, 72], [84, 108], ]) expect(resizeTaskSegmentEnd(segments, 'first', 60).map((segment) => [ segment.start_frame, segment.end_frame, ])).toEqual([ [0, 60], [60, 84], [96, 120], ]) }) it('increases total length when editing the duration of an earlier task', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'first', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'second', start_frame: 24, end_frame: 48, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] const updated = updateMultiTrackSegmentDuration(data, 'first', 1.5, 24) 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('snaps resize edges to timeline and neighboring segment boundaries inside a brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'previous', start_frame: 0, end_frame: 24, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, { id: 'selected', start_frame: 31, end_frame: 60, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, { id: 'next', start_frame: 73, end_frame: 96, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackResizeTime(trackData, 'selected', 'start', 27, 4)).toBe(24) expect(snapMultiTrackResizeTime(trackData, 'selected', 'end', 70, 4)).toBe(73) expect(snapMultiTrackResizeTime(trackData, 'selected', 'end', 116, 4)).toBe(120) expect(snapMultiTrackResizeTime(trackData, 'selected', 'end', 67, 4)).toBe(67) }) it('snaps resize edges to the current timeline playhead inside the brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 0, end_frame: 60, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackResizeTime(trackData, 'selected', 'end', 46, 4, 48)).toBe(48) }) it('snaps moved segment starts to timeline and other segment starts inside a brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 31, end_frame: 60, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, { id: 'other', start_frame: 73, end_frame: 96, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackMoveStartTime(trackData, 'selected', 70, 4)).toBe(73) expect(snapMultiTrackMoveStartTime(trackData, 'selected', 116, 4)).toBe(120) expect(snapMultiTrackMoveStartTime(trackData, 'selected', 67, 4)).toBe(67) }) it('snaps moved segment ends to timeline and other segment starts inside a brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 31, end_frame: 60, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, { id: 'other', start_frame: 73, end_frame: 96, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackMoveStartTime(trackData, 'selected', 43, 4)).toBe(44) expect(snapMultiTrackMoveStartTime(trackData, 'selected', 92, 4)).toBe(91) expect(snapMultiTrackMoveStartTime(trackData, 'selected', 88, 2)).toBe(88) }) it('snaps moved segment starts to the current timeline playhead inside the brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 0, end_frame: 24, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackMoveStartTime(trackData, 'selected', 46, 4, 48)).toBe(48) }) it('snaps moved segment ends to the current timeline playhead inside the brake distance', () => { const data = createDefaultTrackData() const trackData = { ...data, total_length: 120, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'selected', start_frame: 0, end_frame: 24, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } expect(snapMultiTrackMoveStartTime(trackData, 'selected', 23, 4, 48)).toBe(24) }) it('converts seconds and frames using the current frame rate', () => { expect(secondsToFrame(1.5, 24)).toBe(36) expect(frameToSeconds(36, 24)).toBe(1.5) }) it('snaps arbitrary frame values to integer frame boundaries', () => { expect(snapTimeToFrame(1.02, 24)).toBe(1) expect(snapTimeToFrame(1.5, 24)).toBe(2) }) it('remaps segment frames when changing frame rate without changing displayed time', () => { const data = createDefaultTrackData() const trackData = { ...data, tracks: [ data.tracks[0], { ...data.tracks[1], segments: [ { id: 'video-remap', start_frame: 24, end_frame: 48, color: data.tracks[1].color, content: { media_type: 'video' as const }, }, ], }, ], } const updated = remapTrackDataFrameRate(trackData, 30) expect(updated.frame_rate).toBe(30) expect(updated.total_length).toBe(60) expect(updated.tracks[1].segments[0]).toMatchObject({ start_frame: 30, end_frame: 60, }) expect(formatMultiTrackTime(updated.tracks[1].segments[0].start_frame, { frameRate: updated.frame_rate, showFrames: true })).toBe('00:01:00') expect(remapTrackDataFrameRate(data, 16).total_length).toBe(80) }) it('formats multitrack time as clock labels with optional frame precision', () => { expect(formatMultiTrackTime(5.9)).toBe('00:05') expect(formatMultiTrackTime(65)).toBe('01:05') expect(formatMultiTrackTime(3661)).toBe('01:01:01') expect(formatMultiTrackTime(6, { frameRate: 24, showFrames: true })).toBe('00:00:06') expect(formatMultiTrackTime(33, { frameRate: 24, showFrames: true })).toBe('00:01:09') expect(formatMultiTrackTime(1572, { frameRate: 24, showFrames: true })).toBe('01:05:12') expect(formatMultiTrackTime(0, { frameRate: 24, showFrames: true })).toBe('00:00:00') }) it('derives total length from the latest segment end frame', () => { const data = createDefaultTrackData() const videoTrack = data.tracks[1] videoTrack.segments = [ { id: 'seg1', start_frame: 120, end_frame: 205, color: 'var(--primary)', content: { media_type: 'video', file_name: 'clip.mp4', }, }, ] expect(calculateTotalLength(data.tracks)).toBe(205) }) it('uses the latest segment end regardless of the current frame rate', () => { const data = createDefaultTrackData() data.tracks[1].segments = [{ id: 'short', start_frame: 0, end_frame: 86, color: 'var(--primary)', content: { media_type: 'video' }, }] expect(calculateTotalLength(data.tracks, 16)).toBe(86) expect(calculateTotalLength(data.tracks, 20)).toBe(86) }) it('adds a default task segment when a video range has no task coverage', () => { const data = createDefaultTrackData() const updated = addDefaultTaskSegmentIfRangeEmpty(data.tracks, 2, 5) const taskTrack = updated[0] expect(taskTrack.segments).toHaveLength(1) expect(taskTrack.segments[0]).toMatchObject({ start_frame: 2, end_frame: 5, color: 'var(--multitrack-task-bg)', content: { media_type: 'none', task_mode: 'default', continuity_mode: 'shot', }, }) }) it('inherits task mode and a custom system prompt for an auto-created task segment', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'previous-task', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'edit', continuity_mode: 'context', ref_image_size: 'max', system_prompt: 'Custom instructions', }, }] const updated = addDefaultTaskSegmentIfRangeEmpty(data.tracks, 2, 5) expect(updated[0].segments[1].content).toMatchObject({ task_mode: 'edit', system_prompt: 'Custom instructions', continuity_mode: 'context', ref_image_size: 'max', }) }) it('preserves character swap continuity while normalizing track data', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'swap-task', start_frame: 0, end_frame: 120, color: data.tracks[0].color, content: { media_type: 'none', continuity_mode: 'context_swap', }, }] const normalized = normalizeTrackData(data) expect(normalized.tracks[0].segments[0].content.continuity_mode).toBe('context_swap') }) it('does not add a default task segment when the range already has task coverage', () => { const data = createDefaultTrackData() const withTask = addDefaultTaskSegmentIfRangeEmpty(data.tracks, 2, 5) const updated = addDefaultTaskSegmentIfRangeEmpty(withTask, 3, 4) expect(updated[0].segments).toHaveLength(1) }) it('keeps task segments when deleting an overlapping video segment', () => { const data = createDefaultTrackData() const taskTrack = { ...data.tracks[0], segments: [ { id: 'task-before', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'task-linked', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, { id: 'task-after', start_frame: 5, end_frame: 7, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, ], } const videoTrack = { ...data.tracks[1], segments: [ { id: 'video-delete', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 3 }, }, ], } const updated = deleteSegmentWithLinkedTasks([taskTrack, videoTrack], 'video-delete') expect(updated[0].segments.map((segment) => segment.id)).toEqual([ 'task-before', 'task-linked', 'task-after', ]) expect(updated[1].segments).toHaveLength(0) }) it('keeps linked tasks when another video track still covers the deleted range', () => { const data = createDefaultTrackData() data.tracks[0].segments = [{ id: 'task-linked', start_frame: 24, end_frame: 72, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }] data.tracks[1].segments = [{ id: 'video-delete', start_frame: 24, end_frame: 72, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }] data.tracks.push({ ...data.tracks[1], id: 'video-track-2', name: 'Video 1', segments: [{ id: 'video-keep', start_frame: 24, end_frame: 72, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }], }) const updated = deleteSegmentWithLinkedTasks(data.tracks, 'video-delete') expect(updated[0].segments.map((segment) => segment.id)).toEqual(['task-linked']) expect(updated[1].segments).toHaveLength(0) expect(updated[2].segments.map((segment) => segment.id)).toEqual(['video-keep']) }) it('does not delete linked tasks when deleting a non-video segment', () => { const data = createDefaultTrackData() const taskTrack = { ...data.tracks[0], segments: [ { id: 'task-delete', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none' as const, task_mode: 'default' as const }, }, ], } const videoTrack = { ...data.tracks[1], segments: [ { id: 'video-keep', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 3 }, }, ], } const updated = deleteSegmentWithLinkedTasks([taskTrack, videoTrack], 'task-delete') expect(updated[0].segments).toHaveLength(0) expect(updated[1].segments.map((segment) => segment.id)).toEqual(['video-keep']) }) it('deletes only explicitly selected segments in a multi-selection', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-linked', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-selected', start_frame: 2, end_frame: 4, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] data.tracks[1].segments = [ { id: 'video-selected', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, { id: 'video-keep', start_frame: 2, end_frame: 4, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, ] const updated = deleteSegmentsWithLinkedTasks(data.tracks, ['video-selected', 'task-selected']) expect(updated[0].segments.map((segment) => segment.id)).toEqual(['task-linked']) expect(updated[1].segments.map((segment) => segment.id)).toEqual(['video-keep']) }) it('preserves every task segment after deleting a middle video clip', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-middle', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-last', start_frame: 5, end_frame: 7, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] data.tracks[1].segments = [ { id: 'video-first', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, { id: 'video-middle', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video', duration: 3 }, }, { id: 'video-last', start_frame: 5, end_frame: 7, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, ] const updated = deleteSegmentsWithLinkedTasks(data.tracks, ['video-middle']) expect(updated[1].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['video-first', 0, 2], ['video-last', 5, 7], ]) expect(updated[0].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['task-first', 0, 2], ['task-middle', 2, 5], ['task-last', 5, 7], ]) }) it('moves segments only between compatible tracks and clamps to avoid overlaps', () => { const data = createDefaultTrackData() const firstVideoTrack = data.tracks[1] const secondVideoTrack = { ...firstVideoTrack, id: 'video2', segments: [ { id: 'existing', start_frame: 4, end_frame: 6, color: firstVideoTrack.color, content: { media_type: 'video' as const, duration: 2 }, }, ], } const moving = { id: 'moving', start_frame: 0, end_frame: 2, color: firstVideoTrack.color, content: { media_type: 'video' as const, duration: 2 }, } const moved = moveSegmentBetweenCompatibleTracks( [ data.tracks[0], { ...firstVideoTrack, id: 'video1', segments: [moving] }, secondVideoTrack, ], 'moving', 'video2', 3.5, 24, ) expect(moved[1].segments).toHaveLength(0) expect(moved[2].segments.map((segment) => segment.id)).toEqual(['moving', 'existing']) expect(moved[2].segments[0]).toMatchObject({ start_frame: 4, end_frame: 6, }) expect(moved[2].segments[1]).toMatchObject({ start_frame: 6, end_frame: 8, }) const blocked = moveSegmentBetweenCompatibleTracks(moved, 'moving', data.tracks[0].id, 0, 24) expect(blocked).toBe(moved) }) it('preserves leading video gaps and only shifts clips enough to prevent overlap', () => { const data = createDefaultTrackData() const firstVideoTrack = data.tracks[1] const moved = moveSegmentBetweenCompatibleTracks( [ data.tracks[0], { ...firstVideoTrack, id: 'video1', segments: [ { id: 'first', start_frame: 0, end_frame: 2, color: firstVideoTrack.color, content: { media_type: 'video' as const, duration: 2 }, }, { id: 'second', start_frame: 2, end_frame: 5, color: firstVideoTrack.color, content: { media_type: 'video' as const, duration: 3 }, }, ], }, ], 'first', 'video1', 4, 24, ) expect(moved[1].segments.map((segment) => segment.id)).toEqual(['second', 'first']) expect(moved[1].segments.map((segment) => [segment.start_frame, segment.end_frame])).toEqual([ [2, 5], [5, 7], ]) }) it('moves selected segments together while preserving each track type rules', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-second', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] data.tracks[1].segments = [ { id: 'video-first', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, { id: 'video-second', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video', duration: 3 }, }, ] data.tracks.push({ id: 'audio-track', name: 'Audio 0', type: 'audio', color: 'var(--highlight)', muted: false, solo: false, volume_db: 0, locked: false, segments: [ { id: 'audio-first', start_frame: 0, end_frame: 2, color: 'var(--highlight)', content: { media_type: 'audio', duration: 2 }, }, { id: 'audio-second', start_frame: 3, end_frame: 5, color: 'var(--highlight)', content: { media_type: 'audio', duration: 2 }, }, ], }) const moved = moveSelectedSegments( data.tracks, ['task-first', 'video-first', 'audio-first'], 'video-first', data.tracks[1].id, 4, 24, ) expect(moved[0].segments.map((segment) => segment.id)).toEqual(['task-second', 'task-first']) expect(moved[1].segments.map((segment) => segment.id)).toEqual(['video-second', 'video-first']) expect(moved[2].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['audio-second', 3, 5], ['audio-first', 5, 7], ]) }) it('moves subtitle segments like audio segments without packing gaps', () => { const data = createDefaultTrackData() 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: 2, color: '#9D4937', content: { media_type: 'subtitle', text: 'First' }, }, { id: 'subtitle-second', start_frame: 6, end_frame: 8, color: '#9D4937', content: { media_type: 'subtitle', text: 'Second' }, }, ], }) const moved = moveSegmentBetweenCompatibleTracks( data.tracks, 'subtitle-first', 'subtitle-track', 4, 24, ) expect(moved[2].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['subtitle-first', 4, 6], ['subtitle-second', 6, 8], ]) }) it('moves task segments to arbitrary free ranges without packing existing gaps', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-moving', start_frame: 0, end_frame: 24, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-existing', start_frame: 72, end_frame: 96, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] const moved = moveSegmentBetweenCompatibleTracks( data.tracks, 'task-moving', data.tracks[0].id, 120, data.frame_rate, ) expect(moved[0].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['task-existing', 72, 96], ['task-moving', 120, 144], ]) }) it('previews subtitle drag placement without packing gaps', () => { const data = createDefaultTrackData() 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: 2, color: '#9D4937', content: { media_type: 'subtitle', text: 'First' }, }, { id: 'subtitle-second', start_frame: 6, end_frame: 8, color: '#9D4937', content: { media_type: 'subtitle', text: 'Second' }, }, ], }) const placeholder = getSegmentDragPlaceholder( data.tracks, 'subtitle-first', 'subtitle-track', 4, 24, ) expect(placeholder).toMatchObject({ segmentId: 'subtitle-first', targetTrackId: 'subtitle-track', start_frame: 4, end_frame: 6, }) expect(getSegmentDragPreviewSegments(data.tracks, placeholder!, 24)).toEqual([ expect.objectContaining({ id: 'subtitle-second', start_frame: 6, end_frame: 8 }), ]) }) it('moves matching task ranges together with reordered video segments', () => { const data = createDefaultTrackData() data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-second', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] data.tracks[1].segments = [ { id: 'video-first', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video', duration: 2 }, }, { id: 'video-second', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video', duration: 3 }, }, ] const moved = moveSegmentBetweenCompatibleTracks( data.tracks, 'video-first', data.tracks[1].id, 4, data.frame_rate, ) expect(moved[0].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['task-first', 5, 7], ['task-second', 2, 5], ]) }) it('keeps audio drop positions and source-track gaps while preventing overlaps', () => { const data = createDefaultTrackData() const audioTrack = { ...data.tracks[1], type: 'audio' as const, id: 'audio1', segments: [ { id: 'keep', start_frame: 8, end_frame: 10, color: data.tracks[1].color, content: { media_type: 'audio' as const }, }, { id: 'moving', start_frame: 14, end_frame: 17, color: data.tracks[1].color, content: { media_type: 'audio' as const }, }, ], } const targetTrack = { ...audioTrack, id: 'audio2', segments: [{ id: 'existing', start_frame: 12, end_frame: 15, color: audioTrack.color, content: { media_type: 'audio' as const }, }], } const moved = moveSegmentBetweenCompatibleTracks( [data.tracks[0], audioTrack, targetTrack], 'moving', 'audio2', 11, 24, ) expect(moved[1].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['keep', 8, 10], ]) expect(moved[2].segments.map((segment) => [segment.id, segment.start_frame, segment.end_frame])).toEqual([ ['moving', 11, 14], ['existing', 14, 17], ]) expect(getSegmentDragPlaceholder( [data.tracks[0], audioTrack, targetTrack], 'moving', 'audio2', 20, 24, )).toMatchObject({ start_frame: 20, end_frame: 23 }) }) it('computes drag placeholder only for compatible target tracks', () => { const data = createDefaultTrackData() const videoTrack = { ...data.tracks[1], id: 'video1', segments: [ { id: 'first', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 2 }, }, { id: 'second', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 3 }, }, ], } expect(getSegmentDragPlaceholder([data.tracks[0], videoTrack], 'first', data.tracks[0].id, 4, 24)).toBeNull() expect(getSegmentDragPlaceholder([data.tracks[0], videoTrack], 'first', 'video1', 4, 24)).toEqual({ segmentId: 'first', targetTrackId: 'video1', insertIndex: 1, start_frame: 5, end_frame: 7, }) expect(getSegmentDragPlaceholder([data.tracks[0], videoTrack], 'second', 'video1', 0, 24)).toEqual({ segmentId: 'second', targetTrackId: 'video1', insertIndex: 0, start_frame: 0, end_frame: 3, }) }) it('temporarily repacks visible target-track segments around the drag placeholder', () => { const data = createDefaultTrackData() const videoTrack = { ...data.tracks[1], id: 'video1', segments: [ { id: 'first', start_frame: 0, end_frame: 2, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 2 }, }, { id: 'second', start_frame: 2, end_frame: 5, color: data.tracks[1].color, content: { media_type: 'video' as const, duration: 3 }, }, ], } const placeholder = getSegmentDragPlaceholder([data.tracks[0], videoTrack], 'second', 'video1', 0, 24) data.tracks[0].segments = [ { id: 'task-first', start_frame: 0, end_frame: 2, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, { id: 'task-second', start_frame: 2, end_frame: 5, color: data.tracks[0].color, content: { media_type: 'none', task_mode: 'default' }, }, ] expect(placeholder).not.toBeNull() expect(getSegmentDragPreviewSegments([data.tracks[0], videoTrack], placeholder!, 24)?.map((segment) => ({ id: segment.id, start_frame: segment.start_frame, end_frame: segment.end_frame, }))).toEqual([ { id: 'first', start_frame: 3, end_frame: 5 }, { id: 'task-first', start_frame: 3, end_frame: 5 }, { id: 'task-second', start_frame: 0, end_frame: 3 }, ]) }) })