@@ -62,6 +62,12 @@ public class Keyframe {
6262 * keyframes. A value {@code <= 0} means "unspecified": the viewer's timepoint is left untouched.
6363 */
6464 public int timepoint = -1 ;
65+ /**
66+ * Viewer (canvas) size in pixels at capture, or {@code -1} if unspecified. The viewer transform is
67+ * in screen pixels, so it depends on the canvas size: see {@link #transformFor(int, int)}.
68+ */
69+ public int width = -1 , height = -1 ;
70+
6571 /**
6672 * Per-source display settings (levels and LUT color), keyed by 0-based source index. Interpolated
6773 * between keyframes for sources present in both; sources absent from the map are left untouched.
@@ -162,6 +168,8 @@ public Keyframe(final String serialized) {
162168 this .frames = parsed .frames ;
163169 this .timepoint = parsed .timepoint ;
164170 this .display .putAll (parsed .display );
171+ this .width = parsed .width ;
172+ this .height = parsed .height ;
165173 }
166174
167175 public Keyframe (final AffineTransform3D transform , final double dCam , final double nearClip ,
@@ -219,13 +227,66 @@ private String getAccelNameSerialized() {
219227 return getAccelName ().replace (' ' , '_' );
220228 }
221229
230+ /**
231+ * Returns a copy of the viewer transform adjusted to a canvas of the given size. Mirrors what the
232+ * viewer does when resized (the scene center stays at the canvas center): the translation is
233+ * shifted by half the size difference. The transform is returned unchanged if this keyframe has
234+ * no recorded size.
235+ *
236+ * @param canvasWidth the current canvas width in pixels
237+ * @param canvasHeight the current canvas height in pixels
238+ * @return a new transform
239+ */
240+ public AffineTransform3D transformFor (final int canvasWidth , final int canvasHeight ) {
241+ final AffineTransform3D t = transform .copy ();
242+ if (width > 0 && height > 0 ) {
243+ t .set (t .get (0 , 3 ) + 0.5 * (canvasWidth - width ), 0 , 3 );
244+ t .set (t .get (1 , 3 ) + 0.5 * (canvasHeight - height ), 1 , 3 );
245+ }
246+ return t ;
247+ }
248+
249+ /**
250+ * Sets the transition length (chainable, for scripts).
251+ *
252+ * @param frames number of frames of the transition into this keyframe
253+ * @return this keyframe
254+ */
255+ public Keyframe frames (final int frames ) {
256+ this .frames = frames ;
257+ return this ;
258+ }
259+
260+ /**
261+ * Sets the easing type by name (chainable, for scripts).
262+ *
263+ * @param name the easing name, see {@link #setAccel(String)}
264+ * @return this keyframe
265+ */
266+ public Keyframe accel (final String name ) {
267+ setAccel (name );
268+ return this ;
269+ }
270+
222271 /**
223272 * Serializes this keyframe to a single-line string:
224- * {@code transform=d0,d1,...,d11|cam=dCam,near,far|visible=a;b;c|accel=name|frames=N|t=N|display=i:min,max,#RRGGBB;...}
225- * (the timepoint and display entries are omitted if unspecified).
273+ * {@code transform=d0,d1,...,d11|cam=dCam,near,far|visible=a;b;c|accel=name|frames=N|t=N|size=WxH| display=i:min,max,#RRGGBB;...}
274+ * (the timepoint, size and display entries are omitted if unspecified).
226275 */
227276 @ Override
228277 public String toString () {
278+ return toString (true );
279+ }
280+
281+ /**
282+ * Serializes this keyframe, optionally leaving out the timing entries ({@code accel} and
283+ * {@code frames}), e.g., when they are set separately through {@link #frames(int)} and
284+ * {@link #accel(String)}.
285+ *
286+ * @param includeTiming whether to include the {@code accel} and {@code frames} entries
287+ * @return the serialized keyframe
288+ */
289+ public String toString (final boolean includeTiming ) {
229290 final StringBuilder sb = new StringBuilder ("transform=" );
230291 final double [] m = transform .getRowPackedCopy ();
231292 for (int i = 0 ; i < m .length ; i ++) {
@@ -236,9 +297,12 @@ public String toString() {
236297 // Sanitize actor names: replace ';' to avoid breaking the delimiter
237298 sb .append ("|visible=" ).append (visibleActors .stream ().map (a -> a .replace (';' , '_' ))
238299 .collect (Collectors .joining (";" )));
239- sb .append ("|accel=" ).append (getAccelNameSerialized ());
240- sb .append ("|frames=" ).append (frames );
300+ if (includeTiming ) {
301+ sb .append ("|accel=" ).append (getAccelNameSerialized ());
302+ sb .append ("|frames=" ).append (frames );
303+ }
241304 if (timepoint > 0 ) sb .append ("|t=" ).append (timepoint );
305+ if (width > 0 && height > 0 ) sb .append ("|size=" ).append (width ).append ('x' ).append (height );
242306 if (!display .isEmpty ()) {
243307 sb .append ("|display=" ).append (display .entrySet ().stream ().map (e -> {
244308 final SourceDisplay d = e .getValue ();
@@ -290,6 +354,12 @@ public static Keyframe fromString(final String s) {
290354 final int frames = Integer .parseInt (parts .getOrDefault ("frames" , "60" ));
291355 final Keyframe kf = new Keyframe (t , dc , nc , fc , vis , accel , frames );
292356 kf .timepoint = Integer .parseInt (parts .getOrDefault ("t" , "-1" ));
357+ final String sizeStr = parts .getOrDefault ("size" , "" );
358+ if (!sizeStr .isBlank ()) {
359+ final String [] wh = sizeStr .split ("x" );
360+ kf .width = Integer .parseInt (wh [0 ].trim ());
361+ kf .height = Integer .parseInt (wh [1 ].trim ());
362+ }
293363 final String dispStr = parts .getOrDefault ("display" , "" );
294364 if (!dispStr .isBlank ()) {
295365 for (final String entry : dispStr .split (";" )) {
0 commit comments