290 lines
8.7 KiB
TypeScript
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,
|
|
)
|
|
})
|
|
})
|