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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 14:51:20 +02:00
1 parent c9e8a3f6ac
commit 774207208c
9 files changed
+291 -8

No files matched your search

+55
View File
@@ -8,6 +8,7 @@ import {
import {
clipboardKeyCount,
copyAnimationKeys,
KeyStraightRunCommand,
MoveAnimationKeysCommand,
PasteAnimationKeysCommand,
RemoveAnimationTracksCommand,
@@ -23,16 +24,21 @@ import {
hasKey,
interpolationOf,
type KeyRef,
rowVideoAt,
sameKey,
trackDef,
trackLabel,
trackValueAt,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video';
import { VIDEO_TIME_KEY } from '../../core/properties/videos';
import {
drawBand,
Menu,
type MenuEntry,
pressCount,
type Size,
Theme,
@@ -306,6 +312,42 @@ export class TimelineCanvas extends Widget {
);
}
// --- a driven video at 1× (#407) -----------------------------------------------------------
/** The two keys that play the row's video at 1× from the playhead to the clip's end, or null. */
videoRun(trackId: string): { from: FrameValue; to: FrameValue; length: number } | null {
const asset = this.asset();
const track = asset?.tracks.find((t) => t.id === trackId);
if (!asset || !track || track.relative || trackDef(this.state.project, asset, track)?.kind !== 'number')
return null;
const at = this.playheadFrame;
const video = rowVideoAt(this.state.project, asset, track, at);
if (!video) return null;
const value = Number(trackValueAt(track.keys, 'number', at) ?? 0);
const length = videoLengthOf(video);
const to = realTimeEnd(at, value, length, fpsOf(asset));
return to && { from: { frame: at, value }, to, length };
}
keyVideoRun(trackId: string): boolean {
const asset = this.asset();
const run = this.videoRun(trackId);
if (!asset || !run) return false;
const command = new KeyStraightRunCommand(asset.id, trackId, run.from, run.to);
if (!this.state.execute(command)) return false;
this.state.select(trackSelection(asset.id, [{ trackId, frame: run.to.frame }], trackId));
const seconds = (s: number) => `${Number(s.toFixed(3))} s`;
const gone =
command.removed === 0
? ''
: `, ${command.removed} ${command.removed === 1 ? 'key' : 'keys'} between removed`;
this.state.setNotice(
`Video at 1×: frame ${run.to.frame} shows ${seconds(run.to.value)} of ${seconds(run.length)}${gone}.`,
'quiet',
);
return true;
}
/**
* A right-click on a key picks it first, the way a node's menu does, so Copy speaks about it; one
* on a row's header picks the row. Delete row always means the row under the pointer.
@@ -322,6 +364,18 @@ export class TimelineCanvas extends Widget {
this.state.select(trackSelection(asset.id, [], track.id));
const picked = this.state.selectedKeys.length;
const at = this.playheadFrame;
const timeRow = track ?? asset.tracks.find((t) => t.id === this.state.selectedTrackId);
const videoRun: MenuEntry[] =
timeRow?.property === VIDEO_TIME_KEY
? [
{ separator: true },
{
label: `Video at 1× to its end from frame ${at}`,
disabled: this.videoRun(timeRow.id) === null,
onSelect: () => this.keyVideoRun(timeRow.id),
},
]
: [];
new Menu([
{
label: picked > 1 ? `Copy ${picked} keys` : 'Copy key',
@@ -336,6 +390,7 @@ export class TimelineCanvas extends Widget {
{ separator: true },
{ label: `Insert frames at ${at}…`, onSelect: () => this.openFramesDialog('insert') },
{ label: `Remove frames from ${at}…`, danger: true, onSelect: () => this.openFramesDialog('remove') },
...videoRun,
{ separator: true },
{
label: 'Delete row',
+7 -4
View File
@@ -20,7 +20,8 @@ export const HEADER_WIDTH = 220;
/** Height of the frame ruler above the rows. */
export const RULER_HEIGHT = 22;
export const ROW_HEIGHT = 26;
export const MIN_CELL_WIDTH = 4;
/** Far enough out that an hour of video at 30 fps fits a screen (#407): cells then share a pixel. */
export const MIN_CELL_WIDTH = 0.02;
export const MAX_CELL_WIDTH = 48;
const DEFAULT_CELL_WIDTH = 16;
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
@@ -80,10 +81,12 @@ export function clampPan(view: TimelineView): TimelineView {
return view.panX > 0 ? { ...view, panX: 0 } : view;
}
/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */
/** Every `n`-th frame gets a number on the ruler; a round step (1, 2, 5, 10, …) so the numbers read like a scale. */
export function rulerStep(view: TimelineView): number {
const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
return steps.find((step) => step * view.cellWidth >= RULER_LABEL_MIN_PX) ?? 1000;
if (!(view.cellWidth > 0)) return 1;
for (let decade = 1; ; decade *= 10)
for (const step of [decade, 2 * decade, 5 * decade])
if (step * view.cellWidth >= RULER_LABEL_MIN_PX) return step;
}
/**