Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
632 changes: 388 additions & 244 deletions src/mosaic/animation/animations.py

Large diffs are not rendered by default.

113 changes: 66 additions & 47 deletions src/mosaic/animation/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
QHBoxLayout,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
Expand Down Expand Up @@ -80,6 +82,7 @@ def __init__(self, vtk_widget, volume_viewer=None, cdata=None, parent=None):
self.vtk_widget = vtk_widget
self.volume_viewer = volume_viewer

self.setWindowTitle("Animation")
self._setup_ui()
self._setup_shortcuts()

Expand Down Expand Up @@ -152,12 +155,18 @@ def _setup_ui(self):
self.timeline.content.trackSelected.connect(self._on_track_selected)
self.timeline.frameMoved.connect(self._frame_throttle)
self.timeline.trackMoved.connect(self._on_track_moved)
self.timeline.trackResized.connect(self._on_track_resized)
self.timeline.trackRemoved.connect(self.delete_track)
self.timeline.setMinimumHeight(120)
self.timeline.setMinimumHeight(220)
self.timeline.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
main_layout.addWidget(self.timeline, 1)

# Settings scroll below the timeline instead of squeezing it
settings = QWidget()
settings_layout = QVBoxLayout(settings)
settings_layout.setContentsMargins(0, 0, 0, 0)
settings_layout.setSpacing(8)

presets_group = QGroupBox("Presets")
presets_layout = QVBoxLayout(presets_group)
Expand All @@ -181,7 +190,7 @@ def _setup_ui(self):
btn.clicked.connect(lambda _, p=preset_type: self._load_preset(p))
presets_row.addWidget(btn)
presets_layout.addLayout(presets_row)
main_layout.addWidget(presets_group)
settings_layout.addWidget(presets_group)

# Add Animation group (2 rows x 3 cols for compactness)
anims_group = QGroupBox("Add Animation")
Expand All @@ -197,11 +206,35 @@ def _setup_ui(self):
btn.setFixedHeight(Colors.WIDGET_HEIGHT)
btn.clicked.connect(lambda _, t=anim_type: self.add_animation(t))
anims_layout.addWidget(btn, i // cols, i % cols)
main_layout.addWidget(anims_group)
settings_layout.addWidget(anims_group)

self.properties_panel = AnimationSettings()
self.properties_panel.animationChanged.connect(self._on_animation_changed)
main_layout.addWidget(self.properties_panel)
settings_layout.addWidget(self.properties_panel)
settings_layout.addStretch()

settings_scroll = QScrollArea()
settings_scroll.setWidget(settings)
settings_scroll.setWidgetResizable(True)
settings_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
settings_scroll.setStyleSheet(
"QScrollArea { border: none; background: transparent; }"
)
settings.setObjectName("composerSettings")
settings.setStyleSheet("#composerSettings { background: transparent; }")

splitter = QSplitter(Qt.Orientation.Vertical)
splitter.addWidget(self.timeline)
splitter.addWidget(settings_scroll)
splitter.setChildrenCollapsible(False)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 1)
splitter.setSizes([400, 400])
splitter.setHandleWidth(6)
splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
main_layout.addWidget(splitter, 1)

self.setMinimumWidth(280)
self.resize(300, 700)
Expand Down Expand Up @@ -264,8 +297,7 @@ def _load_reveal_flythrough_preset(self):

This creates a sequence that:
1. Slices through the volume (forward) with all actors hidden
2. Reveals all actors at the midpoint
3. Slices back through the volume (backward)
2. Slices back through the volume (backward) with actors revealed
"""
if self.volume_viewer is None:
MosaicMessageBox.warning(
Expand All @@ -286,36 +318,18 @@ def _load_reveal_flythrough_preset(self):
for _, obj in self.cdata.format_datalist("models"):
all_objects.append(id(obj))

hide_anim = self.add_animation(AnimationType.VISIBILITY)
if hide_anim:
hide_anim.name = "Hide Actors"
hide_anim.update_parameters(
start_opacity=1.0,
target_opacity=0.0,
easing="instant",
selected_objects=all_objects,
)
hide_anim.start_frame = 0
hide_anim.stop_frame = 1

forward_slice = self.add_animation(AnimationType.SLICE)
if forward_slice:
forward_slice.name = "Slice Forward"
forward_slice.update_parameters(direction="forward")

reveal_anim = self.add_animation(AnimationType.VISIBILITY)
if reveal_anim:
reveal_anim.name = "Reveal Actors"
reveal_anim.update_parameters(
start_opacity=0.0,
target_opacity=1.0,
easing="instant",
selected_objects=all_objects,
)
reveal_anim.start_frame = 0
reveal_anim.stop_frame = 1
hide_anim = self.add_animation(AnimationType.VISIBILITY)
if hide_anim:
hide_anim.name = "Hide Actors"
hide_anim.update_parameters(selected_objects=all_objects)
hide_anim.global_start_frame = forward_slice.global_start_frame
hide_anim.set_duration(forward_slice.duration)

# 4. Volume slice backward
backward_slice = self.add_animation(AnimationType.SLICE)
if backward_slice:
backward_slice.name = "Slice Backward"
Expand Down Expand Up @@ -360,11 +374,12 @@ def _get_next_start_frame(self) -> int:
)

def _cleanup_tracks(self, tracks=None):
"""Reset animation state and remove preview actors for the given tracks."""
for track in tracks or self.tracks:
"""Restore state modified by the given tracks."""
tracks = tracks or self.tracks
for track in tracks:
track.animation.reset()
if hasattr(track.animation, "cleanup_preview"):
track.animation.cleanup_preview()
for track in tracks:
track.animation.commit()

def _get_track(self, track_id: str):
return next((t for t in self.tracks if t.id == track_id), None)
Expand Down Expand Up @@ -392,9 +407,22 @@ def _on_track_moved(self, track_id: str, new_frame: int):
track.animation.global_start_frame = new_frame
self.properties_panel.global_start_spin.setValue(new_frame)
self.timeline.update()
self._frame_throttle(self.current_frame)

def _on_track_resized(self, track_id: str, duration: int):
if (track := self._get_track(track_id)) is None:
return None

track.animation.set_duration(duration)
if self.selected_track == track_id:
self.properties_panel.sync_timing()
self.timeline.update()
self._frame_throttle(self.current_frame)
return None

def _on_animation_changed(self, changes: dict[str, Any]):
self.timeline.update()
self._frame_throttle(self.current_frame)

def _get_total_frames(self) -> int:
"""Get the total number of frames across all tracks."""
Expand Down Expand Up @@ -431,30 +459,23 @@ def set_current_frame(self, frame: int):
track.animation.reset()
for track in self.tracks:
track.animation.update(self.current_frame)
for track in self.tracks:
track.animation.commit()

self.vtk_widget.GetRenderWindow().Render()
self.cdata.viewport.render_vtk()

def _go_to_end(self):
"""Jump to the last frame."""
self.set_current_frame(self._get_total_frames())

def _set_preview_visibility(self, visible: bool):
"""Show or hide preview actors (e.g. waypoint path) on all tracks."""
for track in self.tracks:
for actor in getattr(track.animation, "_path_actors", []):
actor.SetVisibility(visible)
self.vtk_widget.GetRenderWindow().Render()

def toggle_play(self):
"""Toggle playback state."""
self.is_playing = not self.is_playing

if self.is_playing:
self._set_preview_visibility(False)
self.play_btn.setIcon(icon("ph.pause", role="muted"))
self.timer.start(1000 // self.playback_fps)
else:
self._set_preview_visibility(True)
self.play_btn.setIcon(icon("ph.play", role="primary"))
self.timer.stop()

Expand Down Expand Up @@ -636,7 +657,6 @@ def export_animation(self):
n_frames = len(frames)

try:
self._set_preview_visibility(False)
render_window.SetOffScreenRendering(1)
render_window.SetMultiSamples(multisamples)
render_window.SetAlphaBitPlanes(1 if transparent_bg else 0)
Expand Down Expand Up @@ -687,7 +707,6 @@ def export_animation(self):
finally:
if indicator is not None:
indicator.hide_progress()
self._set_preview_visibility(True)
render_window.SetAlphaBitPlanes(original_alpha)
render_window.SetMultiSamples(original_multisamples)
render_window.SetOffScreenRendering(0)
Expand Down
66 changes: 44 additions & 22 deletions src/mosaic/animation/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,17 @@ def setup_ui(self):
)
frame_layout.addWidget(self.global_start_spin, 0, 1)

frame_layout.addWidget(QLabel("Rate:"), 0, 2)
frame_layout.addWidget(QLabel("Duration:"), 0, 2)
self.duration_spin = QSpinBox()
self.duration_spin.setRange(1, 2 << 29)
self.duration_spin.setSuffix(" f")
self.duration_spin.setFixedHeight(Colors.WIDGET_HEIGHT)
self.duration_spin.setMinimumWidth(0)
self.duration_spin.valueChanged.connect(self._on_duration_changed)
frame_layout.addWidget(self.duration_spin, 0, 3)

self._rate_label = QLabel("Rate:")
frame_layout.addWidget(self._rate_label, 1, 0)
self.rate_spin = QDoubleSpinBox()
self.rate_spin.setRange(0.1, 10.0)
self.rate_spin.setValue(1.0)
Expand All @@ -80,9 +90,7 @@ def setup_ui(self):
self.rate_spin.setFixedHeight(Colors.WIDGET_HEIGHT)
self.rate_spin.setMinimumWidth(0)
self.rate_spin.valueChanged.connect(self._on_rate_changed)
frame_layout.addWidget(self.rate_spin, 0, 3)

self._base_duration = 100 # Reference duration at rate=1.0
frame_layout.addWidget(self.rate_spin, 1, 1)

main_layout.addLayout(frame_layout)

Expand Down Expand Up @@ -114,13 +122,37 @@ def _on_theme_changed(self):
label.setStyleSheet(muted)

def _on_rate_changed(self, rate):
"""Handle rate change by updating the derived stop_frame."""
if rate <= 0:
if not self.animation:
return None
self.animation.set_rate(rate)
self.sync_timing()
self.animationChanged.emit({"rate": rate})
return None

def _on_duration_changed(self, frames):
if not self.animation:
return None
self.animation.set_duration(frames)
self.sync_timing()
self.animationChanged.emit({"duration": frames})
return None

def sync_timing(self):
"""Refresh duration and rate widgets from the current animation."""
if not self.animation:
return None
new_duration = max(1, int(self._base_duration / rate))
if self.animation:
self.animation.rate = rate
self.on_change(new_duration, "stop_frame")

for widget in (self.duration_spin, self.rate_spin):
widget.blockSignals(True)
self.duration_spin.setValue(self.animation.duration)
self.rate_spin.setValue(self.animation.rate)
for widget in (self.duration_spin, self.rate_spin):
widget.blockSignals(False)

has_rate = self.animation.has_rate
self._rate_label.setVisible(has_rate)
self.rate_spin.setVisible(has_rate)
return None

def set_animation(self, animation):
self.animation = animation
Expand All @@ -130,25 +162,18 @@ def set_animation(self, animation):
self.name_edit,
self.global_start_spin,
self.enabled_check,
self.rate_spin,
]:
widget.blockSignals(True)

self.name_edit.setText(animation.name)
self.global_start_spin.setValue(animation.global_start_frame)
self.enabled_check.setChecked(animation.enabled)

if animation._base_duration is not None:
self._base_duration = animation._base_duration
else:
self._base_duration = animation.stop_frame * animation.rate
self.rate_spin.setValue(animation.rate)
self.sync_timing()

for widget in [
self.name_edit,
self.global_start_spin,
self.enabled_check,
self.rate_spin,
]:
widget.blockSignals(False)

Expand Down Expand Up @@ -209,10 +234,7 @@ def on_change(self, value, key):

# Data range changes update stop_frame; sync the UI
if key in ("data_start", "data_stop"):
self._base_duration = self.animation._base_duration or self._base_duration
self.rate_spin.blockSignals(True)
self.rate_spin.setValue(self.animation.rate)
self.rate_spin.blockSignals(False)
self.sync_timing()

self.animationChanged.emit({key: value})

Expand Down
Loading
Loading