Files
feuerwerk/tests/editor/timeline-view.test.ts
T
adminandClaude Opus 5.5 774207208c Key a video to its end at 1x and zoom the timeline further out (#407)
To play a clip at its real speed the user had to know its length,
work out the last whole frame by hand and then scroll far to the
right to set the end key. The timeline now does the sum, and it
zooms out far enough to see a long clip in one piece.

- The context menu of a Video time row gets "Video at 1x to its end
  from frame N". It keys the playhead with the row's value there and
  the last whole frame before the clip ends with the matching time.
- The start key turns linear and keys in between are removed. The
  whole run is one undo step (KeyStraightRunCommand).
- The clip is the one the sprite wears at the playhead, also when
  the texture is keyed in the same animation (rowVideoAt).
- The entry is greyed out for relative rows, without a video, while
  the length is unknown, or when the playhead is already at the end.
- The status line names the result, e.g. "frame 572 shows 57.2 s of
  57.27 s".
- The timeline zooms out to 0.02 px per frame instead of 4 px. The
  ruler labels follow a 1-2-5 step without an upper limit.

Tests:
- Unit video-clock + timeline-view: 27 tests green, 8 new
- e2e 66 (atx, alone): ALL PASSED, 23 checks, 66l new
- Gate (atx): CHECK EXIT 0, 297 files, 4553 tests
- Counter-check: the command keeping the keys in between turns
  exactly the command test red

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:51:20 +02:00

138 lines
4.4 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,
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('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);
});
});