Files
LTX-DeskWEB/frontend/lib/multi-keyframe.test.ts

290 lines
8.7 KiB
TypeScript

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { persistedKeyframeSchema } from '../types/project-model.ts'
import { pickFreeFrameIndex } from './keyframe-timeline.ts'
import {
appendKeyframePaths,
applyKeyframeImagePaths,
DEFAULT_KEYFRAME_STRENGTH,
MISSING_KEYFRAME_STRENGTH,
fromPersistedKeyframes,
toPersistedKeyframes,
videoGenerationModeFromInputs,
enhanceKeyframesPayload,
type KeyframeItem,
} from './multi-keyframe.ts'
function item(
id: string,
path: string,
frameIndex: number,
strength = DEFAULT_KEYFRAME_STRENGTH,
): KeyframeItem {
return { id, path, frameIndex, strength }
}
describe('rapid keyframe placement', () => {
it('never assigns the same frame index twice', () => {
const placed: { frameIndex: number }[] = []
for (let count = 0; count < 5; count++) {
const frameIndex = pickFreeFrameIndex(placed, 120, 0)
assert.notEqual(frameIndex, null)
placed.push({ frameIndex: frameIndex! })
}
assert.equal(new Set(placed.map(({ frameIndex }) => frameIndex)).size, placed.length)
})
})
describe('appendKeyframePaths', () => {
it('spreads a burst of images across free frames', () => {
let nextId = 0
const added = appendKeyframePaths(
[],
['/a.png', '/b.png', '/c.png'],
120,
0,
5,
() => `id-${nextId++}`,
)
assert.deepEqual(
added.map(({ path, frameIndex, strength }) => ({ path, frameIndex, strength })),
[
{ path: '/a.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
{ path: '/b.png', frameIndex: 60, strength: DEFAULT_KEYFRAME_STRENGTH },
{ path: '/c.png', frameIndex: 90, strength: DEFAULT_KEYFRAME_STRENGTH },
],
)
assert.equal(new Set(added.map(({ frameIndex }) => frameIndex)).size, 3)
})
it('stops at the remaining cap', () => {
const existing: KeyframeItem[] = [
item('opening', '/opening.png', 0),
item('closing', '/closing.png', 10),
]
const added = appendKeyframePaths(
existing,
['/a.png', '/b.png', '/c.png'],
20,
0,
3,
() => 'extra',
)
assert.equal(added.length, 3)
assert.equal(added.at(-1)?.path, '/a.png')
})
})
describe('applyKeyframeImagePaths', () => {
const existing: KeyframeItem[] = [
item('opening', '/opening.png', 0),
]
it('replaces one marker and appends leftover files', () => {
let nextId = 0
const next = applyKeyframeImagePaths({
keyframes: existing,
paths: ['/new-opening.png', '/middle.png'],
replaceId: 'opening',
lastFrame: 80,
preferredFrame: 0,
maxCount: 5,
createId: () => `id-${nextId++}`,
})
assert.equal(next[0]?.path, '/new-opening.png')
assert.equal(next[0]?.frameIndex, 0)
assert.equal(next[1]?.path, '/middle.png')
assert.notEqual(next[1]?.frameIndex, 0)
})
it('replaces without appending when duration is unknown', () => {
const next = applyKeyframeImagePaths({
keyframes: existing,
paths: ['/new-opening.png', '/ignored.png'],
replaceId: 'opening',
lastFrame: null,
preferredFrame: 0,
maxCount: 5,
})
assert.deepEqual(next, [item('opening', '/new-opening.png', 0)])
})
it('keeps the still\'s owned strength when replacing its image', () => {
const next = applyKeyframeImagePaths({
keyframes: [item('opening', '/opening.png', 0, 0.7)],
paths: ['/new-opening.png'],
replaceId: 'opening',
lastFrame: 80,
preferredFrame: 0,
maxCount: 5,
})
assert.deepEqual(next, [item('opening', '/new-opening.png', 0, 0.7)])
})
})
describe('persisted keyframes', () => {
it('drops UI ids when snapshotting for generationParams', () => {
assert.deepEqual(
toPersistedKeyframes([
item('opening', '/opening.png', 0),
item('closing', '/closing.png', 80, 0.7),
]),
[
{ path: '/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
{ path: '/closing.png', frameIndex: 80, strength: 0.7 },
],
)
})
it('restores items with fresh ids and owned strength', () => {
let nextId = 0
const restored = fromPersistedKeyframes(
[{ path: '/opening.png', frameIndex: 0, strength: 0.7 }],
() => `id-${nextId++}`,
)
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, 0.7)])
})
it('fills missing persisted strength with a full lock, not the new-still default', () => {
let nextId = 0
const restored = fromPersistedKeyframes(
[{ path: '/opening.png', frameIndex: 0 }],
() => `id-${nextId++}`,
)
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, MISSING_KEYFRAME_STRENGTH)])
assert.notEqual(MISSING_KEYFRAME_STRENGTH, DEFAULT_KEYFRAME_STRENGTH)
})
it('persists and restores a zero lock instead of treating it as missing', () => {
assert.deepEqual(
toPersistedKeyframes([item('opening', '/opening.png', 0, 0)]),
[{ path: '/opening.png', frameIndex: 0, strength: 0 }],
)
let nextId = 0
const restored = fromPersistedKeyframes(
[{ path: '/opening.png', frameIndex: 0, strength: 0 }],
() => `id-${nextId++}`,
)
assert.deepEqual(restored, [item('id-0', '/opening.png', 0, 0)])
})
it('clamps restored strength to the 0-1 lock range', () => {
let nextId = 0
const restored = fromPersistedKeyframes(
[
{ path: '/hi.png', frameIndex: 0, strength: 1.5 },
{ path: '/lo.png', frameIndex: 40, strength: -0.2 },
],
() => `id-${nextId++}`,
)
assert.deepEqual(restored, [
item('id-0', '/hi.png', 0, 1),
item('id-1', '/lo.png', 40, 0),
])
})
})
describe('videoGenerationModeFromInputs', () => {
it('prefers multi-keyframe over image and audio inputs', () => {
assert.equal(
videoGenerationModeFromInputs({
keyframes: [{ path: '/opening.png', frameIndex: 0 }],
audioUrl: '/clip.mp3',
imageUrl: '/still.png',
}),
'multi-keyframe',
)
})
it('falls back to text-to-video when nothing is attached', () => {
assert.equal(videoGenerationModeFromInputs({}), 'text-to-video')
})
})
describe('enhanceKeyframesPayload', () => {
it('returns undefined when there are no stills', () => {
assert.equal(enhanceKeyframesPayload([]), undefined)
})
it('maps a single still onto the enhance keyframe list', () => {
assert.deepEqual(
enhanceKeyframesPayload([item('opening', '/opening.png', 40)]),
[{ imagePath: '/opening.png', frameIndex: 40, strength: DEFAULT_KEYFRAME_STRENGTH }],
)
})
it('sends every still, including middle markers, in frame order', () => {
assert.deepEqual(
enhanceKeyframesPayload([
item('closing', '/closing.png', 80),
item('opening', '/opening.png', 0),
item('middle', '/middle.png', 40),
]),
[
{ imagePath: '/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
{ imagePath: '/middle.png', frameIndex: 40, strength: DEFAULT_KEYFRAME_STRENGTH },
{ imagePath: '/closing.png', frameIndex: 80, strength: DEFAULT_KEYFRAME_STRENGTH },
],
)
})
it('forwards each still\'s owned strength instead of hardcoding the default', () => {
assert.deepEqual(
enhanceKeyframesPayload([item('opening', '/opening.png', 0, 0.7)]),
[{ imagePath: '/opening.png', frameIndex: 0, strength: 0.7 }],
)
})
it('forwards a zero lock instead of treating it as missing', () => {
assert.deepEqual(
enhanceKeyframesPayload([item('opening', '/opening.png', 0, 0)]),
[{ imagePath: '/opening.png', frameIndex: 0, strength: 0 }],
)
})
it('clamps out-of-range strength on the enhance payload', () => {
assert.deepEqual(
enhanceKeyframesPayload([
item('hi', '/hi.png', 0, 1.5),
item('lo', '/lo.png', 40, -0.2),
]),
[
{ imagePath: '/hi.png', frameIndex: 0, strength: 1 },
{ imagePath: '/lo.png', frameIndex: 40, strength: 0 },
],
)
})
})
describe('persistedKeyframeSchema', () => {
it('defaults missing strength to a full lock', () => {
assert.deepEqual(
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0 }),
{ path: '/opening.png', frameIndex: 0, strength: MISSING_KEYFRAME_STRENGTH },
)
})
it('keeps a zero lock and clamps out of range so project parse cannot fail', () => {
assert.equal(
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: 0 }).strength,
0,
)
assert.equal(
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: 1.5 }).strength,
1,
)
assert.equal(
persistedKeyframeSchema.parse({ path: '/opening.png', frameIndex: 0, strength: -0.2 }).strength,
0,
)
})
})