The rows of the animation timeline stood in the order they were keyed. With several objects and properties the list was hard to read, and a value could only be changed by picking a key and using the inspector. The Play button also kept saying Pause after scrubbing had paused. - Each object gets a header row with its name, indented by depth. The groups follow the scene hierarchy, the root first. Rows whose object is missing go to the end. - Under an object the rows follow the order in which it lists its properties: X, Y, Z, then the order of the inspector. Only the view is sorted, the animation itself is unchanged and there is no undo step. After a move in the hierarchy the order is right by itself. - The row head shows the same field as the inspector with the value at the playhead. Without keys it shows the value of the object, for a relative row the offset. - A change in that field sets or changes the key at the playhead, selects it and is one undo step. A running playback pauses first. - The Play/Pause button follows every change of the playing state, so scrubbing that pauses shows Play again. Tests: - Unit animation + timeline-view: 100 passed, 3 new. - e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups, order and values at frame 4, a typed 55 becomes a selected key on frame 4, Play then a click on the ruler shows Play and pauses). - Gate on melektaus-atx: exit 0, 297 files, 4561 tests. - tsc, eslint, Prettier clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { AnimationTrack } from '../../src/core/model/Asset';
|
|
import {
|
|
clampMove,
|
|
clampPan,
|
|
DEFAULT_VIEW,
|
|
frameAt,
|
|
frameCentreX,
|
|
frameToX,
|
|
keyAt,
|
|
keysWithin,
|
|
MAX_CELL_WIDTH,
|
|
MIN_CELL_WIDTH,
|
|
ROW_HEIGHT,
|
|
rowAt,
|
|
rowCentreY,
|
|
rowTop,
|
|
RULER_HEIGHT,
|
|
rulerStep,
|
|
type TimelineView,
|
|
xToFrame,
|
|
zoomCells,
|
|
} from '../../src/editor/animation/timelineView';
|
|
|
|
const TRACKS: AnimationTrack[] = [
|
|
{
|
|
id: 'a',
|
|
target: '',
|
|
property: 'x',
|
|
keys: [
|
|
{ frame: 0, value: 0 },
|
|
{ frame: 4, value: 40 },
|
|
],
|
|
},
|
|
{ id: 'b', target: '', property: 'y', keys: [{ frame: 2, value: 0 }] },
|
|
];
|
|
|
|
describe('timeline geometry', () => {
|
|
it('maps frames to cells and back', () => {
|
|
const view: TimelineView = { panX: 10, cellWidth: 20 };
|
|
expect(frameToX(view, 0)).toBe(10);
|
|
expect(frameToX(view, 3)).toBe(70);
|
|
expect(frameCentreX(view, 3)).toBe(80);
|
|
expect(xToFrame(view, 80)).toBe(3.5);
|
|
expect(frameAt(view, 89)).toBe(3);
|
|
expect(frameAt(view, 90)).toBe(4);
|
|
});
|
|
|
|
it('never answers a frame below 0', () => {
|
|
expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0);
|
|
});
|
|
|
|
it('finds rows below the ruler and nothing beyond the last', () => {
|
|
expect(rowAt(RULER_HEIGHT - 1, 2)).toBe(-1);
|
|
expect(rowAt(RULER_HEIGHT, 2)).toBe(0);
|
|
expect(rowAt(RULER_HEIGHT + ROW_HEIGHT, 2)).toBe(1);
|
|
expect(rowAt(RULER_HEIGHT + 2 * ROW_HEIGHT, 2)).toBe(-1);
|
|
});
|
|
|
|
it('zooms about the pointer, so the frame under it stays put', () => {
|
|
const view: TimelineView = { panX: -100, cellWidth: 16 };
|
|
const x = 200;
|
|
const before = xToFrame(view, x);
|
|
const zoomed = zoomCells(view, 1.5, x);
|
|
expect(zoomed.cellWidth).toBe(24);
|
|
expect(xToFrame(zoomed, x)).toBeCloseTo(before);
|
|
});
|
|
|
|
it('keeps the cell width within its bounds', () => {
|
|
expect(zoomCells(DEFAULT_VIEW, 100, 0).cellWidth).toBe(MAX_CELL_WIDTH);
|
|
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
|
|
});
|
|
|
|
it('zooms out far enough that an hour of video at 30 fps fits a screen (#407)', () => {
|
|
const out = zoomCells(DEFAULT_VIEW, 1e-6, 0);
|
|
expect(frameToX(out, 3600 * 30)).toBeLessThanOrEqual(2200);
|
|
});
|
|
|
|
it('does not let frame 0 drift right of the left edge', () => {
|
|
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
|
|
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
|
|
});
|
|
|
|
it('labels the ruler with a step that keeps the numbers apart', () => {
|
|
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
|
|
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
|
|
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
|
|
expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500);
|
|
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000);
|
|
});
|
|
});
|
|
|
|
describe('picking keys', () => {
|
|
const view: TimelineView = { panX: 0, cellWidth: 16 };
|
|
|
|
it('hits the key drawn at the centre of its cell', () => {
|
|
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({
|
|
trackId: 'a',
|
|
frame: 4,
|
|
});
|
|
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({
|
|
trackId: 'b',
|
|
frame: 2,
|
|
});
|
|
});
|
|
|
|
it('misses an empty cell and the ruler', () => {
|
|
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull();
|
|
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull();
|
|
});
|
|
|
|
it('a band catches every key whose diamond centre it spans, across rows', () => {
|
|
const a = { x: frameToX(view, 1), y: RULER_HEIGHT };
|
|
const b = { x: frameToX(view, 5), y: RULER_HEIGHT + 2 * ROW_HEIGHT };
|
|
expect(keysWithin(view, TRACKS, a, b)).toEqual([
|
|
{ trackId: 'a', frame: 4 },
|
|
{ trackId: 'b', frame: 2 },
|
|
]);
|
|
// Spanned the other way round it is the same band.
|
|
expect(keysWithin(view, TRACKS, b, a)).toHaveLength(2);
|
|
});
|
|
|
|
it('a band over one row leaves the other alone', () => {
|
|
const a = { x: frameToX(view, 0), y: RULER_HEIGHT };
|
|
const b = { x: frameToX(view, 10), y: RULER_HEIGHT + ROW_HEIGHT - 1 };
|
|
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
|
|
});
|
|
|
|
it("skips an object's head between the rows (#408)", () => {
|
|
const lanes = [null, TRACKS[0]!, null, TRACKS[1]!];
|
|
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toBeNull();
|
|
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual({
|
|
trackId: 'a',
|
|
frame: 4,
|
|
});
|
|
expect(keyAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })?.trackId).toBe('b');
|
|
const all = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(4) });
|
|
expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']);
|
|
});
|
|
|
|
it('a move never takes a key before frame 0', () => {
|
|
const keys = [
|
|
{ trackId: 'a', frame: 2 },
|
|
{ trackId: 'b', frame: 5 },
|
|
];
|
|
expect(clampMove(keys, -5)).toBe(-2);
|
|
expect(clampMove(keys, 3)).toBe(3);
|
|
expect(clampMove([], -3)).toBe(0);
|
|
});
|
|
});
|