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

+38
View File
@@ -243,6 +243,44 @@ export class SetAnimationKeyCommand extends TimelineCommand {
}
}
/**
* Keys a number row straight from one value to another (#407): both keys set, the first one linear,
* the keys between them gone – what a driven video needs to run at 1× without a bend.
*/
export class KeyStraightRunCommand extends TimelineCommand {
readonly label = 'Key video at 1×';
/** How many keys between the two were taken out. */
removed = 0;
constructor(
assetId: string,
private readonly trackId: string,
private readonly from: AnimationKey,
private readonly to: AnimationKey,
) {
super(assetId);
}
protected apply(project: Project, asset: AnimationAsset): void {
const track = requireTrack(asset, this.trackId);
requireFrame(this.from.frame, 'A key sits on a whole frame from 0');
requireFrame(this.to.frame, 'A key sits on a whole frame from 0');
if (this.to.frame <= this.from.frame) throw new CommandError('The run has to end after it starts');
const def = trackDef(project, asset, track);
if (def?.kind !== 'number') throw new CommandError('Only a number row can run straight');
const kept = track.keys.filter((k) => k.frame < this.from.frame || k.frame > this.to.frame);
const end = track.keys.find((k) => k.frame === this.to.frame);
this.removed = track.keys.filter((k) => k.frame > this.from.frame && k.frame < this.to.frame).length;
const last: AnimationKey = { frame: this.to.frame, value: coerceValue(def, this.to.value) };
if (end?.interpolation) last.interpolation = end.interpolation;
track.keys = normalizeKeys([
...kept,
{ frame: this.from.frame, value: coerceValue(def, this.from.value) },
last,
]);
}
}
/**
* Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row
* that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused.
+21
View File
@@ -23,6 +23,7 @@ import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project';
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
import { objectName, type SceneObject } from './SceneObject';
import { findVideo, type VideoRef } from './video';
import { mixColor } from '../util/color';
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
@@ -522,6 +523,26 @@ export function trackDef(
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
}
/**
* The video the object a row aims at wears at `frame`, for a row shown only with one (#407): what
* the texture row the def names holds there – keyed in this animation, or else on the object.
*/
export function rowVideoAt(
project: Project,
animation: AnimationAsset,
track: AnimationTrack,
frame: number,
): VideoRef | null {
const aimed = aimedAt(project, animation, track.target);
const def =
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
const textureKey = def && 'shownForVideo' in def ? def.shownForVideo : undefined;
if (!aimed || !textureKey) return null;
const keyed = animation.tracks.find((t) => t.target === track.target && t.property === textureKey);
const value = (keyed && trackValueAt(keyed.keys, 'texture', frame)) ?? aimed.bag[textureKey];
return findVideo(project, textOf(value ?? ''));
}
/**
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
* private variables (#270) and what another row animates already. A row on a private variable keeps
+25
View File
@@ -72,6 +72,31 @@ export function videoPlayOf(ref: VideoRef): VideoPlay {
};
}
/** Seconds a video or clip lasts; 0 while the video's length is not known yet. */
export function videoLengthOf(ref: VideoRef): number {
const play = videoPlayOf(ref);
const end = Number.isFinite(play.end) ? play.end : ref.asset.duration;
return end > play.start ? end - play.start : 0;
}
/** A key of a `Video time` row: seconds into the clip on a frame of the animation. */
export interface FrameValue {
frame: number;
value: number;
}
/**
* Where a driven video playing at 1× from `value` seconds on `frame` reaches the end of a clip
* `length` seconds long (#407): the last whole frame at `fps` before it, and the clip's time there.
* Null when not one frame fits.
*/
export function realTimeEnd(frame: number, value: number, length: number, fps: number): FrameValue | null {
if (!(length > 0) || !(fps > 0) || !Number.isFinite(value)) return null;
const frames = Math.floor((length - value) * fps + 1e-6);
if (frames < 1) return null;
return { frame: frame + frames, value: Math.round((value + frames / fps) * 1e6) / 1e6 };
}
const PREFIX = 'video:';
/**