import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { findNearestFreeFrameIndex, formatKeyframeTimecode, frameFromPointer, keyframeAtOrBefore, lastFrameFromDuration, parseKeyframeTimecode, pickFreeFrameIndex, previewKeyframeForPlayhead, retimeKeyframes, retimeKeyframesForSettings, sameDraggedFrame, withDraggedFrame, } from './keyframe-timeline.ts' type TestKeyframe = { frameIndex: number; id: string; path?: string } function keyframe(frameIndex: number, id = `file-${frameIndex}`): TestKeyframe { return { frameIndex, id } } describe('lastFrameFromDuration', () => { it('matches backend compute_num_frames - 1 so stills cannot sit past the clip', () => { assert.equal(lastFrameFromDuration(5, 24), 120) assert.equal(lastFrameFromDuration(5, 25), 120) assert.equal(lastFrameFromDuration(5, 50), 248) assert.equal(lastFrameFromDuration(10, 24), 240) assert.equal(lastFrameFromDuration(10, 25), 248) assert.equal(lastFrameFromDuration(10, 50), 496) assert.equal(lastFrameFromDuration(4, 50), 200) }) }) describe('frameFromPointer', () => { const trackRect = { left: 100, width: 200 } const lastFrame = 100 it('maps the left edge to frame 0', () => { assert.equal(frameFromPointer(100, trackRect, lastFrame), 0) }) it('maps the right edge to lastFrame', () => { assert.equal(frameFromPointer(300, trackRect, lastFrame), lastFrame) }) it('maps the midpoint to the middle frame', () => { assert.equal(frameFromPointer(200, trackRect, lastFrame), 50) }) }) describe('formatKeyframeTimecode', () => { const cases = [ { fps: 24, frameIndex: 0, expected: '00:00.00' }, { fps: 24, frameIndex: 23, expected: '00:00.23' }, { fps: 24, frameIndex: 24, expected: '00:01.00' }, { fps: 25, frameIndex: 24, expected: '00:00.24' }, { fps: 25, frameIndex: 25, expected: '00:01.00' }, { fps: 48, frameIndex: 49, expected: '00:01.01' }, { fps: 50, frameIndex: 3005, expected: '01:00.05' }, ] as const for (const { fps, frameIndex, expected } of cases) { it(`renders frame ${frameIndex} at ${fps} fps as ${expected}`, () => { assert.equal(formatKeyframeTimecode(frameIndex, fps), expected) }) } }) describe('parseKeyframeTimecode', () => { it('accepts a frames field only below the frame rate', () => { assert.equal(parseKeyframeTimecode('00:01.24', 25), 49) assert.equal(parseKeyframeTimecode('00:01.25', 25), null) assert.equal(parseKeyframeTimecode('00:01.25', 48), 73) }) it('defaults a missing frames field to the first frame of that second', () => { assert.equal(parseKeyframeTimecode('01:02', 24), 62 * 24) }) for (const value of ['', '1', '00:60.00', '00:01.', 'a:bb.cc', '00:01:02']) { it(`rejects ${JSON.stringify(value)}`, () => { assert.equal(parseKeyframeTimecode(value, 25), null) }) } for (const fps of [24, 25, 48, 50]) { it(`round-trips every frame of two seconds at ${fps} fps`, () => { for (let frameIndex = 0; frameIndex < fps * 2; frameIndex++) { assert.equal( parseKeyframeTimecode(formatKeyframeTimecode(frameIndex, fps), fps), frameIndex, ) } }) } }) describe('pickFreeFrameIndex', () => { it('uses the preferred frame when it is free', () => { assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(100)], 200, 40), 40) }) it('clamps a preferred frame past the end onto the last frame', () => { assert.equal(pickFreeFrameIndex([], 200, 500), 200) }) it('falls back to the middle of the widest free stretch', () => { assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(9)], 10, 0), 4) }) it('spreads a burst of adds instead of clumping them at the playhead', () => { const taken: TestKeyframe[] = [] for (let i = 0; i < 4; i++) { const frameIndex = pickFreeFrameIndex(taken, 100, 0) assert.notEqual(frameIndex, null) taken.push(keyframe(frameIndex!)) } assert.equal(new Set(taken.map(({ frameIndex }) => frameIndex)).size, 4) assert.deepEqual(taken.map(({ frameIndex }) => frameIndex), [0, 50, 75, 25]) }) it('returns null when every frame is taken', () => { assert.equal(pickFreeFrameIndex([keyframe(0), keyframe(1), keyframe(2)], 2, 1), null) }) it('ignores taken indices outside the legal range when checking fullness', () => { assert.equal(pickFreeFrameIndex([keyframe(-1), keyframe(100)], 1, 0), 0) }) }) describe('findNearestFreeFrameIndex', () => { it('never returns an occupied index', () => { const taken = [keyframe(10), keyframe(11), keyframe(12)] const occupied = new Set(taken.map(({ frameIndex }) => frameIndex)) for (let target = 0; target <= 20; target++) { const result = findNearestFreeFrameIndex(taken, target, 20) if (result !== null) { assert.equal(occupied.has(result), false) } } }) it('lands on an adjacent free frame when dropped on an occupied one', () => { assert.equal(findNearestFreeFrameIndex([keyframe(10)], 10, 100), 11) assert.equal(findNearestFreeFrameIndex([keyframe(10), keyframe(11)], 10, 100), 9) }) it('does not jump to a far gap when a nearer frame is free', () => { assert.equal(findNearestFreeFrameIndex([keyframe(50)], 50, 100), 51) assert.notEqual(findNearestFreeFrameIndex([keyframe(50)], 50, 100), 0) }) it('ignores taken indices outside the legal range', () => { assert.equal(findNearestFreeFrameIndex([keyframe(-1), keyframe(100)], 0, 1), 0) }) }) describe('retimeKeyframes', () => { it('keeps keyframes at the same relative place when the frame rate changes', () => { const retimed = retimeKeyframes([keyframe(0), keyframe(50)], 249, 499) assert.deepEqual( retimed.map((k) => k.frameIndex), [0, 100], ) }) it('scales keyframes across a shorter clip instead of packing the tail', () => { const retimed = retimeKeyframes( [keyframe(0), keyframe(100), keyframe(200)], 200, 100, ) assert.deepEqual( retimed.map((k) => k.frameIndex), [0, 50, 100], ) }) it('scales keyframes across a longer clip instead of leaving them bunched', () => { const retimed = retimeKeyframes( [keyframe(0), keyframe(50), keyframe(100)], 100, 200, ) assert.deepEqual( retimed.map((k) => k.frameIndex), [0, 100, 200], ) }) it('never collapses two keyframes onto one frame', () => { const retimed = retimeKeyframes([keyframe(10), keyframe(11)], 100, 100) assert.equal(new Set(retimed.map((k) => k.frameIndex)).size, 2) assert.deepEqual( retimed.map((k) => k.id), ['file-10', 'file-11'], ) }) it('drops excess keyframes when there are more keys than slots', () => { const retimed = retimeKeyframes( [keyframe(0, 'a'), keyframe(50, 'b'), keyframe(100, 'c')], 100, 1, ) assert.equal(retimed.length, 2) assert.equal(new Set(retimed.map((k) => k.frameIndex)).size, 2) assert.deepEqual( retimed.map((k) => k.frameIndex), [0, 1], ) assert.deepEqual(retimed.map((k) => k.id), ['a', 'c']) }) }) describe('retimeKeyframesForSettings', () => { it('retimes retained keyframes for settings changes made outside multi-keyframe mode', () => { const keyframes = [ { id: 'opening', path: '/opening.png', frameIndex: 25 }, { id: 'closing', path: '/closing.png', frameIndex: 200 }, ] assert.deepEqual( retimeKeyframesForSettings( keyframes, { duration: 10, fps: 25 }, { duration: 10, fps: 50 }, ), [ { id: 'opening', path: '/opening.png', frameIndex: 50 }, { id: 'closing', path: '/closing.png', frameIndex: 400 }, ], ) }) it('scales a 10s clip down to 5s at 24 fps without packing the tail', () => { const keyframes = [ { id: 'opening', path: '/opening.png', frameIndex: 0 }, { id: 'middle', path: '/middle.png', frameIndex: 120 }, { id: 'closing', path: '/closing.png', frameIndex: 240 }, ] const retimed = retimeKeyframesForSettings( keyframes, { duration: 10, fps: 24 }, { duration: 5, fps: 24 }, ) assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [0, 60, 120]) assert.equal(new Set(retimed.map(({ frameIndex }) => frameIndex)).size, retimed.length) assert.deepEqual( retimed.map(({ id, path }) => ({ id, path })), keyframes.map(({ id, path }) => ({ id, path })), ) }) it('scales a 5s clip up to 10s at 24 fps instead of leaving keys bunched', () => { const retimed = retimeKeyframesForSettings( [ { id: 'opening', path: '/opening.png', frameIndex: 0 }, { id: 'middle', path: '/middle.png', frameIndex: 60 }, { id: 'closing', path: '/closing.png', frameIndex: 120 }, ], { duration: 5, fps: 24 }, { duration: 10, fps: 24 }, ) assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [0, 120, 240]) }) it('must not treat 10s-authored stills as a scale-up from the 5s remount default', () => { const authoredOn10s = [ { id: 'opening', path: '/opening.png', frameIndex: 0 }, { id: 'middle', path: '/middle.png', frameIndex: 120 }, { id: 'closing', path: '/closing.png', frameIndex: 240 }, ] const remountDefault = { duration: 5, fps: 24 } const recoveredClip = { duration: 10, fps: 24 } // Reload mid-generation restores stills that already live on the recovered // clip. If the duration/fps effect still thinks previous=5s, it scales // frame 240 as if it were the 5s tail and bunches keys at the new end. assert.deepEqual( retimeKeyframesForSettings(authoredOn10s, remountDefault, recoveredClip).map( ({ frameIndex }) => frameIndex, ), [0, 239, 240], ) assert.deepEqual( retimeKeyframesForSettings(authoredOn10s, recoveredClip, recoveredClip).map( ({ frameIndex }) => frameIndex, ), [0, 120, 240], ) }) it('retimes and clamps when Auto changes to a numeric duration', () => { const retimed = retimeKeyframesForSettings( [keyframe(75), keyframe(100)], { duration: null, fps: 25 }, { duration: 4, fps: 50 }, ) assert.deepEqual(retimed.map(({ frameIndex }) => frameIndex), [156, 200]) }) }) describe('keyframeAtOrBefore', () => { const opening = keyframe(0, 'opening') const middle = keyframe(40, 'middle') const closing = keyframe(80, 'closing') it('returns the keyframe on the playhead', () => { assert.equal(keyframeAtOrBefore([opening, middle, closing], 40), middle) }) it('holds the previous keyframe in a gap', () => { assert.equal(keyframeAtOrBefore([opening, middle, closing], 55), middle) }) it('falls back to the first keyframe before any marker', () => { assert.equal(keyframeAtOrBefore([middle, closing], 10), middle) }) it('returns undefined when there are no keyframes', () => { assert.equal(keyframeAtOrBefore([], 12), undefined) }) }) describe('withDraggedFrame', () => { const opening = keyframe(0, 'opening') const closing = keyframe(80, 'closing') it('leaves committed positions alone when nothing is dragging', () => { const keyframes = [opening, closing] assert.equal(withDraggedFrame(keyframes, null), keyframes) }) it('overlays the in-flight frame on the dragged keyframe only', () => { const displayed = withDraggedFrame([opening, closing], { id: 'opening', frameIndex: 40 }) assert.deepEqual(displayed.map(({ id, frameIndex }) => ({ id, frameIndex })), [ { id: 'opening', frameIndex: 40 }, { id: 'closing', frameIndex: 80 }, ]) assert.equal(displayed[1], closing) }) }) describe('previewKeyframeForPlayhead', () => { const opening = keyframe(0, 'opening') const closing = keyframe(80, 'closing') it('keeps showing the dragged still after it passes a later committed marker', () => { const preview = previewKeyframeForPlayhead( [opening, closing], 90, { id: 'opening', frameIndex: 90 }, ) assert.equal(preview?.id, 'opening') }) it('uses committed positions when the pointer is not dragging', () => { assert.equal(previewKeyframeForPlayhead([opening, closing], 90, null)?.id, 'closing') }) }) describe('sameDraggedFrame', () => { it('treats identical id+frame overlays as unchanged so setState can bail out', () => { assert.equal( sameDraggedFrame({ id: 'opening', frameIndex: 12 }, { id: 'opening', frameIndex: 12 }), true, ) assert.equal( sameDraggedFrame({ id: 'opening', frameIndex: 12 }, { id: 'opening', frameIndex: 13 }), false, ) assert.equal(sameDraggedFrame(null, null), true) }) })