Skip to content

Commit 64ea0d3

Browse files
committed
Simplify keyframe recording
1 parent e8c02bf commit 64ea0d3

3 files changed

Lines changed: 115 additions & 58 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: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,47 @@ private String getAccelNameSerialized() {
219219
return getAccelName().replace(' ', '_');
220220
}
221221

222+
/**
223+
* Sets the transition length (chainable, for scripts).
224+
*
225+
* @param frames number of frames of the transition into this keyframe
226+
* @return this keyframe
227+
*/
228+
public Keyframe frames(final int frames) {
229+
this.frames = frames;
230+
return this;
231+
}
232+
233+
/**
234+
* Sets the easing type by name (chainable, for scripts).
235+
*
236+
* @param name the easing name, see {@link #setAccel(String)}
237+
* @return this keyframe
238+
*/
239+
public Keyframe accel(final String name) {
240+
setAccel(name);
241+
return this;
242+
}
243+
222244
/**
223245
* Serializes this keyframe to a single-line string:
224246
* {@code transform=d0,d1,...,d11|cam=dCam,near,far|visible=a;b;c|accel=name|frames=N|t=N|display=i:min,max,#RRGGBB;...}
225247
* (the timepoint and display entries are omitted if unspecified).
226248
*/
227249
@Override
228250
public String toString() {
251+
return toString(true);
252+
}
253+
254+
/**
255+
* Serializes this keyframe, optionally leaving out the timing entries ({@code accel} and
256+
* {@code frames}), e.g., when they are set separately through {@link #frames(int)} and
257+
* {@link #accel(String)}.
258+
*
259+
* @param includeTiming whether to include the {@code accel} and {@code frames} entries
260+
* @return the serialized keyframe
261+
*/
262+
public String toString(final boolean includeTiming) {
229263
final StringBuilder sb = new StringBuilder("transform=");
230264
final double[] m = transform.getRowPackedCopy();
231265
for (int i = 0; i < m.length; i++) {
@@ -236,8 +270,10 @@ public String toString() {
236270
// Sanitize actor names: replace ';' to avoid breaking the delimiter
237271
sb.append("|visible=").append(visibleActors.stream().map(a -> a.replace(';', '_'))
238272
.collect(Collectors.joining(";")));
239-
sb.append("|accel=").append(getAccelNameSerialized());
240-
sb.append("|frames=").append(frames);
273+
if (includeTiming) {
274+
sb.append("|accel=").append(getAccelNameSerialized());
275+
sb.append("|frames=").append(frames);
276+
}
241277
if (timepoint > 0) sb.append("|t=").append(timepoint);
242278
if (!display.isEmpty()) {
243279
sb.append("|display=").append(display.entrySet().stream().map(e -> {

‎src/main/resources/script_templates/Neuroanatomy/Recipes/BigViewerRecording.groovy‎

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,30 @@
55
* # Workflow
66
* 1. Open your data in SNT's BVV or BDV
77
* 2. Navigate to a view you like and press 'K' to capture a keyframe.
8-
* Each press prints a line to the console, e.g.:
9-
* kf00 = new Keyframe("transform=...|cam=...|visible=...|accel=symmetric|frames=60")
10-
* The line is also copied to the clipboard.
8+
* Each press appends a line to a running 'kfs' block that is printed to the console and
9+
* copied to the clipboard, e.g.:
10+
* def kfs = [
11+
* new Keyframe("transform=...|cam=...|visible=...")
12+
* .frames(60).accel("symmetric"),
13+
* ]
1114
* 3. Repeat for each viewpoint (rotate, zoom, toggle channels, adjust slab, annotations, etc.)
12-
* 4. Paste all captured keyframes into this script, set the output directory, and run it.
15+
* Press Shift+K to discard the captured keyframes and start over
16+
* 4. Paste the block into this script (replacing the 'kfs' definition below), adjust the frames
17+
* and accel of each keyframe, set the output directory, and run it.
1318
*
1419
* # Keyframes and Keyframe Options
1520
*
1621
* A keyframe stores viewer transform (rotation/zoom/pan), camera depth and slab clipping (BVV only),
1722
* timepoint, display levels and LUT colors (both interpolated between keyframes), visible
1823
* channels/paths/annotations, easing curve, and the number of frames for the transition into that
19-
* keyframe. A serialized keyframe looks like this:
20-
* kf01 = new Keyframe("transform=0.8,-0.6,0,100,0.6,0.8,0,-200,0,0,1.0,-300|cam=2000,800,800|visible=vol:Ch1;vol:Ch2;paths|accel=slow_start|frames=90")
24+
* keyframe. A captured keyframe looks like this:
25+
* new Keyframe("transform=0.8,-0.6,0,100,0.6,0.8,0,-200,0,0,1.0,-300|cam=2000,800,800|visible=vol:Ch1;vol:Ch2;paths")
26+
* .frames(90).accel("slow_start")
2127
*
22-
* You can adjust per-keyframe properties after capture:
28+
* Edit the trailing calls to adjust the transition into each keyframe:
2329
*
24-
* kf.frames = 90 // transition length (default 60: 2s at 30fps)
25-
* kf.setAccel("slow_end") // see acceleration mode
30+
* .frames(90) // transition length (default 60: 2s at 30fps)
31+
* .accel("slow_end") // see acceleration modes
2632
*
2733
* Acceleration modes (cosine-based easing curves):
2834
* "symmetric" Ease in and out equally (smooth start and stop)
@@ -34,50 +40,37 @@
3440
*
3541
* Tips:
3642
* - "slow_end" may work well for approaching a structure; "slow_start" for pulling away.
37-
* "symmetric" is likely the safest default for most transitions
43+
* - "symmetric" is likely the safest default for most transitions
3844
* - The 'frames' value of the first Keyframe is ignored since it is the starting pose
45+
* - See the 'Recipes' section below for functions that edit recorded keyframes
3946
*/
4047

4148

4249
// Retrieve the most recently created Bvv (or Bdv) instance
4350
def viewer = Bvv.getInstance() ?: Bdv.getInstance()
4451

4552
if (viewer == null) {
46-
println "ERROR: There is no active Bvv or Bdv instance"
47-
return
53+
println "ERROR: There is no active Bvv or Bdv instance"
54+
return
4855
}
4956

50-
// Paste your captured keyframes here (press K in the viewer to capture each one):
51-
// kf00 = new Keyframe("...")
52-
// kf01 = new Keyframe("...")
53-
// kf02 = new Keyframe("...")
54-
55-
// Then list them in playback order:
57+
// Paste the keyframes block copied by the K key here (in playback order):
5658
def kfs = [
57-
// kf00,
58-
// kf01,
59-
// kf02,
59+
// new Keyframe("...")
60+
// .frames(60).accel("symmetric"),
61+
// new Keyframe("...")
62+
// .frames(60).accel("symmetric"),
6063
]
6164

62-
// Recipe: flip the movie vertically. A plain Y mirror cannot be interpolated between keyframes, so
63-
// this rotates 180 degrees about the screen X axis instead (same result for a 2D slice). Uncomment
64-
// to use:
65-
// def flipY = { kf, height ->
66-
// def f = new AffineTransform3D()
67-
// f.set(1, 0, 0, 0,
68-
// 0, -1, 0, height, // y' = -y + height (mirror about the canvas center)
69-
// 0, 0, -1, 0) // z' = -z keeps the transform a proper rotation
70-
// kf.transform.preConcatenate(f)
71-
// }
72-
// kfs.each { flipY(it, viewer.getViewerCanvas().height) }
65+
// Optional edits of the keyframes above (see Recipes below), to be applied before playback/rendering:
66+
// kfs.each { flipY(it, viewer.getViewerCanvas().height) } // flip vertically
7367

7468
if (kfs.isEmpty()) {
75-
println "ERROR: No keyframes defined. Press K in the viewer to capture keyframes,"
76-
println " then paste them above and add them to the kfs list."
77-
return
69+
println "ERROR: No keyframes defined. Press K in the viewer to capture keyframes,"
70+
println " then paste the copied block above."
71+
return
7872
}
7973

80-
8174
def dryRun = true // Set to false to save animation sequence instead of live preview
8275
if (dryRun) {
8376

@@ -90,7 +83,7 @@ if (dryRun) {
9083
// viewer.playback(kfs[1..3]) // equivalent using Groovy list slicing
9184

9285
} else {
93-
86+
9487
// Render to image sequence. Defining first the output directory
9588
// (replace it with your preferred file path)
9689
def outputDir = new File(System.properties['user.home'], "Desktop/snapshots").tap { mkdirs() }
@@ -99,6 +92,21 @@ if (dryRun) {
9992
println " ffmpeg -framerate 30 -i ${outputDir}/frame_%05d.png -c:v libx264 -pix_fmt yuv420p movie.mp4"
10093
}
10194

95+
// Recipes below
96+
97+
/**
98+
* Flip the movie vertically. A plain Y mirror cannot be interpolated between keyframes, so this rotates 180 degrees
99+
* about the screen X axis instead (same result for a 2D slice). Call it on each keyframe before
100+
* playback/rendering (see 'kfs.each' above)
101+
*/
102+
def flipY(kf, height) {
103+
def f = new AffineTransform3D()
104+
f.set(1, 0, 0, 0,
105+
0, -1, 0, height, // y' = -y + height (mirror about the canvas center)
106+
0, 0, -1, 0) // z' = -z keeps the transform a proper rotation
107+
kf.transform.preConcatenate(f)
108+
}
109+
102110

103111
// Imports below
104112
import sc.fiji.snt.viewer.Bvv

0 commit comments

Comments
 (0)