Skip to content

Commit 063e200

Browse files
committed
Re-structure keyframe recording
1 parent e8c02bf commit 063e200

6 files changed

Lines changed: 280 additions & 93 deletions

File tree

‎src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java‎

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -873,9 +873,8 @@ protected static void registerCommandFinderAccelerator(final InputMap sntIMap, f
873873
sntAMap.put(actionKey, commandFinder.getToggleVisibilityAction());
874874
}
875875

876-
/** Auto-incrementing counter for keyframe identifiers (K hotkey) */
877-
private static final java.util.concurrent.atomic.AtomicInteger keyframeCounter =
878-
new java.util.concurrent.atomic.AtomicInteger(1);
876+
/** Script-ready lines of the keyframes captured with the K hotkey since the last reset */
877+
private final List<String> capturedKeyframes = new ArrayList<>();
879878

880879
/**
881880
* Registers SNT's viewer keybindings shared by Bvv and Bdv: {@code M} (marker), {@code H}
@@ -933,9 +932,10 @@ protected void registerSntKeybindings(final InputMap sntIMap, final ActionMap sn
933932
}
934933

935934
/**
936-
* Registers the {@code K} hotkey that captures a {@link Keyframe}: the script-ready line is
937-
* printed to the console and copied to the clipboard. Same {@code sntIMap}/{@code sntAMap}
938-
* constraints as {@link #registerCommandFinderAccelerator}.
935+
* Registers the keyframe hotkeys: {@code K} captures a {@link Keyframe} and appends it to a running
936+
* script-ready {@code kfs} block that is printed to the console and copied to the clipboard, and
937+
* {@code Shift+K} clears that block. Same {@code sntIMap}/{@code sntAMap} constraints as
938+
* {@link #registerCommandFinderAccelerator}.
939939
*
940940
* @param sntIMap the viewer's own SNT-overlay {@link InputMap}, not yet installed
941941
* @param sntAMap the matching {@link ActionMap}, not yet installed
@@ -946,18 +946,32 @@ protected void registerKeyframeCapture(final InputMap sntIMap, final ActionMap s
946946
@Override
947947
public void actionPerformed(final ActionEvent e) {
948948
try {
949-
final int id = keyframeCounter.getAndIncrement();
950-
final String scriptLine = String.format("kf%02d = new Keyframe(\"%s\")", id,
951-
captureKeyframe());
952-
System.out.println(scriptLine);
953-
showViewerMessage("Keyframe #" + id + " captured");
949+
final String line = String.format(" new Keyframe(\"%s\")\n\t.frames(60).accel(\"symmetric\"),",
950+
captureKeyframe().toString(false));
951+
final String block;
952+
synchronized (capturedKeyframes) {
953+
capturedKeyframes.add(line);
954+
block = "def kfs = [\n" + String.join("\n", capturedKeyframes) + "\n]";
955+
showViewerMessage("Keyframe #" + capturedKeyframes.size() + " captured (copied)");
956+
}
957+
System.out.println(block);
954958
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard()
955-
.setContents(new java.awt.datatransfer.StringSelection(scriptLine), null);
959+
.setContents(new java.awt.datatransfer.StringSelection(block), null);
956960
} catch (final Exception ex) {
957961
SNTUtils.log("Keyframe capture failed: " + ex.getMessage());
958962
}
959963
}
960964
});
965+
sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_K, InputEvent.SHIFT_DOWN_MASK), "snt-clear-keyframes");
966+
sntAMap.put("snt-clear-keyframes", new AbstractAction() {
967+
@Override
968+
public void actionPerformed(final ActionEvent e) {
969+
synchronized (capturedKeyframes) {
970+
capturedKeyframes.clear();
971+
}
972+
showViewerMessage("Captured keyframes cleared");
973+
}
974+
});
961975
}
962976

963977
/**
@@ -1411,6 +1425,12 @@ protected JComponent tracingStatusRow(final Actions actions, final AbstractTrace
14111425
}
14121426

14131427
protected class Actions {
1428+
private GuiUtils guiUtils;
1429+
// State for hide-annotations (H key) press/release tracking
1430+
float lastClippingDistance = 100f;
1431+
private boolean hideActive;
1432+
private boolean pathsWereVisible;
1433+
private boolean annotationsWereVisible;
14141434

14151435
Action loadSettingsAction() {
14161436
return new AbstractAction("Load Settings...", IconFactory.menuIcon(IconFactory.GLYPH.IMPORT)) {
@@ -1468,13 +1488,6 @@ public void actionPerformed(final ActionEvent e) {
14681488
}
14691489
};
14701490
}
1471-
private GuiUtils guiUtils;
1472-
// State for hide-annotations (H key) press/release tracking
1473-
float lastClippingDistance = 100f;
1474-
private boolean hideActive;
1475-
private boolean pathsWereVisible;
1476-
private boolean annotationsWereVisible;
1477-
14781491

14791492
/**
14801493
* Creates an {@link Action} that calls {@code onToggle} with the toggle button's

‎src/main/java/sc/fiji/snt/viewer/Keyframe.java‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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(";")) {

‎src/main/java/sc/fiji/snt/viewer/KeyframeRecorder.java‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Keyframe capture() {
8383
if (ann != null && ann.isVisible()) actors.add("annotations");
8484
final Keyframe kf = new Keyframe(viewer.getViewerTransform(), cam[0], cam[1], cam[2], actors, 0);
8585
kf.timepoint = viewer.getCurrentTimepoint();
86+
kf.width = viewer.getViewerWidth();
87+
kf.height = viewer.getViewerHeight();
8688
final ConverterSetups setups = viewer.getConverterSetups();
8789
if (setups != null) {
8890
for (int i = 0; i < srcs.size(); i++) {
@@ -152,6 +154,33 @@ private void showAndWait(final AffineTransform3D t, final int timepoint,
152154
});
153155
}
154156

157+
/**
158+
* Creates the animator interpolating between two transforms. The animator is probed at both ends: if
159+
* it does not reproduce the keyframes (e.g., because it adds the center offset {@code cX/cY} to the
160+
* translation, shifting the whole scene), the inputs are pre-shifted to compensate.
161+
*/
162+
private static SimilarityTransformAnimator animatorFor(final AffineTransform3D a, final AffineTransform3D b,
163+
final double cX, final double cY) {
164+
SimilarityTransformAnimator animator = new SimilarityTransformAnimator(a, b, cX, cY, 0);
165+
final double[] d0 = offset(animator.get(0), a);
166+
final double[] d1 = offset(animator.get(1), b);
167+
if (Math.abs(d0[0]) > 1e-3 || Math.abs(d0[1]) > 1e-3 || Math.abs(d1[0]) > 1e-3 || Math.abs(d1[1]) > 1e-3) {
168+
animator = new SimilarityTransformAnimator(shifted(a, d0), shifted(b, d1), cX, cY, 0);
169+
}
170+
return animator;
171+
}
172+
173+
private static double[] offset(final AffineTransform3D actual, final AffineTransform3D expected) {
174+
return new double[] { actual.get(0, 3) - expected.get(0, 3), actual.get(1, 3) - expected.get(1, 3) };
175+
}
176+
177+
private static AffineTransform3D shifted(final AffineTransform3D t, final double[] delta) {
178+
final AffineTransform3D copy = t.copy();
179+
copy.set(t.get(0, 3) - delta[0], 0, 3);
180+
copy.set(t.get(1, 3) - delta[1], 1, 3);
181+
return copy;
182+
}
183+
155184
private static boolean saveFrame(final Component canvas, final File dir, final int index) {
156185
final BufferedImage bi = new BufferedImage(canvas.getWidth(), canvas.getHeight(),
157186
BufferedImage.TYPE_INT_RGB);
@@ -181,16 +210,24 @@ void render(final List<Keyframe> keyframes, final String outputDir) {
181210
throw new IllegalArgumentException("Cannot create output directory: " + outputDir);
182211

183212
final Component canvas = viewer.getViewerCanvas();
184-
final int cX = canvas.getWidth() / 2;
185-
final int cY = canvas.getHeight() / 2;
213+
// Use the viewer's own notion of its size (the one its transform is centered on), which
214+
// is not necessarily the size of the canvas component, and adjust keyframes captured at another size
215+
final int vw = viewer.getViewerWidth();
216+
final int vh = viewer.getViewerHeight();
217+
final int cX = vw / 2;
218+
final int cY = vh / 2;
219+
final AffineTransform3D[] transforms = new AffineTransform3D[keyframes.size()];
220+
for (int k = 0; k < transforms.length; k++) {
221+
final Keyframe kf = keyframes.get(k);
222+
transforms[k] = kf.transformFor(vw, vh);
223+
}
186224
int globalFrame = 0;
187225
try {
188226
for (int k = 1; k < keyframes.size(); k++) {
189227
final Keyframe from = keyframes.get(k - 1);
190228
final Keyframe to = keyframes.get(k);
191229
final int nFrames = to.frames;
192-
final SimilarityTransformAnimator animator = new SimilarityTransformAnimator(
193-
from.transform, to.transform, cX, cY, 0);
230+
final SimilarityTransformAnimator animator = animatorFor(transforms[k - 1], transforms[k], cX, cY);
194231
final boolean interpolateT = from.timepoint > 0 && to.timepoint > 0;
195232
// Visibility and cam/slab snap at keyframe boundaries
196233
applyState(from);
@@ -210,7 +247,7 @@ void render(final List<Keyframe> keyframes, final String outputDir) {
210247
}
211248
final Keyframe last = keyframes.getLast();
212249
applyState(last);
213-
showAndWait(last.transform, last.timepoint, last.display);
250+
showAndWait(transforms[transforms.length - 1], last.timepoint, last.display);
214251
if (save) {
215252
Thread.sleep(100); // allow final render
216253
saveFrame(canvas, dir, globalFrame);
@@ -223,8 +260,11 @@ void render(final List<Keyframe> keyframes, final String outputDir) {
223260
SNTUtils.log("Movie render failed at frame " + globalFrame + ": " + e.getMessage());
224261
return;
225262
}
226-
if (save)
227-
System.out.println("Movie: " + (globalFrame + 1) + " frames saved to " + dir.getAbsolutePath());
263+
if (save) {
264+
VideoInstructions.write(dir, 30, "frame_%05d.png");
265+
System.out.println("Movie: " + (globalFrame + 1) + " frames saved to " + dir.getAbsolutePath()
266+
+ " (see " + VideoInstructions.FILE_NAME + " to assemble the video)");
267+
}
228268
else
229269
System.out.println("Playback complete: " + (globalFrame + 1) + " frames");
230270
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*-
2+
* #%L
3+
* Fiji distribution of ImageJ for the life sciences.
4+
* %%
5+
* Copyright (C) 2010 - 2026 Fiji developers.
6+
* %%
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as
9+
* published by the Free Software Foundation, either version 3 of the
10+
* License, or (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public
18+
* License along with this program. If not, see
19+
* <http://www.gnu.org/licenses/gpl-3.0.html>.
20+
* #L%
21+
*/
22+
23+
package sc.fiji.snt.viewer;
24+
25+
import java.io.File;
26+
import java.io.IOException;
27+
import java.nio.file.Files;
28+
29+
/**
30+
* Writes the instructions on how to assemble a recorded image sequence into a video using ffmpeg.
31+
* Shared by {@link Viewer3D} and the {@link Bvv}/{@link Bdv} keyframe recorder.
32+
*/
33+
final class VideoInstructions {
34+
35+
static final String FILE_NAME = "-build-video.txt";
36+
37+
private VideoInstructions() {
38+
}
39+
40+
/**
41+
* Writes {@value #FILE_NAME} into the folder of the image sequence. If the file cannot be written,
42+
* the instructions are printed to the console instead.
43+
*
44+
* @param dir the folder holding the image sequence
45+
* @param fps the frame rate of the video
46+
* @param inputPattern the ffmpeg input pattern of the frames, e.g., {@code frame_%05d.png}
47+
*/
48+
static void write(final File dir, final int fps, final String inputPattern) {
49+
final String text = build(dir, fps, inputPattern);
50+
try {
51+
Files.writeString(new File(dir, FILE_NAME).toPath(), text);
52+
} catch (final IOException e) {
53+
System.out.println(text);
54+
}
55+
}
56+
57+
static String build(final File dir, final int fps, final String inputPattern) {
58+
final StringBuilder sb = new StringBuilder(
59+
"The image sequence can be converted into a video using ffmpeg (www.ffmpeg.org):\n");
60+
sb.append(" cd \"").append(dir).append("\"\n");
61+
sb.append(" ffmpeg -framerate ").append(fps).append(" -i ").append(inputPattern)
62+
.append(" -vf \"crop=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p\" video.mp4\n\n");
63+
sb.append("- The crop filter trims at most 1 pixel so that width and height are even, as required\n");
64+
sb.append(" by yuv420p. Remove it if your frames already have even dimensions\n\n");
65+
sb.append("- Parameters that can be added in front of crop in the comma-separated list of -vf \"\" options:\n");
66+
sb.append(" hflip flip sequence horizontally\n");
67+
sb.append(" vflip flip sequence vertically\n");
68+
sb.append(" transpose=0 90 degrees counterclockwise and vertical flip\n");
69+
sb.append(" transpose=1 90 degrees clockwise\n");
70+
sb.append(" transpose=2 90 degrees counterclockwise\n");
71+
sb.append(" transpose=3 90 degrees clockwise and vertical flip\n\n");
72+
sb.append("- To use all images in a folder use e.g.:\n");
73+
sb.append(" ffmpeg -framerate ").append(fps)
74+
.append(" -pattern_type glob -i \"*.png\" -vf \"(...)\" video.mp4\n\n");
75+
sb.append("Alternatively, ImageJ built-in commands can also be used, e.g.:\n");
76+
sb.append("\"File>Import>Image Sequence...\", followed by \"File>Save As>AVI...\"");
77+
return sb.toString();
78+
}
79+
}

0 commit comments

Comments
 (0)