Moxie-Multimedia / frontend /src /tests /multitrack-utils.test.ts
turtle89431's picture
Upload folder using huggingface_hub
cbbe5c7 verified
Raw History Blame Contribute Delete
67.3 kB
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<typeof normalizeTrackData>[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<typeof normalizeTrackData>[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<typeof normalizeTrackData>[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<typeof normalizeTrackData>[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 },
])
})
})