Repository: spacetelescope/jdaviz

Issue:
Possibility to save movies out of cubeviz
For presentations, it would be nice to be able to save a gif or some other video format of a portion or a full cube.

[🐱](https://jira.stsci.edu/browse/JDAT-2785)

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope) Report
Patch coverage: **`51.49`**% and project coverage change: **`-0.32`** :warning:
> Comparison is base [(`40116b9`)](https://app.codecov.io/gh/spacetelescope/jdaviz/commit/40116b981daf09a69bb5ffd9f87eae7fd5594f29?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope) 91.37% compared to head [(`dc4046c`)](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope) 91.05%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2264      +/-   ##
==========================================
- Coverage   91.37%   91.05%   -0.32%     
==========================================
  Files         150      151       +1     
  Lines       16862    16996     +134     
==========================================
+ Hits        15407    15476      +69     
- Misses       1455     1520      +65     
```


| [Impacted Files](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope) | Coverage Δ | |
|---|---|---|
| [...configs/default/plugins/export\_plot/export\_plot.py](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope#diff-amRhdml6L2NvbmZpZ3MvZGVmYXVsdC9wbHVnaW5zL2V4cG9ydF9wbG90L2V4cG9ydF9wbG90LnB5) | `43.10% <41.17%> (-5.29%)` | :arrow_down: |
| [...configs/cubeviz/plugins/tests/test\_export\_plots.py](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope#diff-amRhdml6L2NvbmZpZ3MvY3ViZXZpei9wbHVnaW5zL3Rlc3RzL3Rlc3RfZXhwb3J0X3Bsb3RzLnB5) | `69.38% <69.38%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/spacetelescope/jdaviz/pull/2264?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spacetelescope).

pllim: devdeps failure is unrelated; I reported it upstream at https://github.com/newville/asteval/issues/120
camipacifici: Looks very nice, thank you!
A few thoughts:
- There should be a note at the top of the "Export video" to mention the needed package and a link to its installation instructions.
- it would be useful to be able to select the viewer (I created a smoothed cube and opened it in the uncertainty viewer and to save that one out I had to load it in the flux viewer first. I would be easier to just select the viewer I want)
- In standalone, I could not find the movie. Maybe a note to tell the user that they have to set the full path (rev: I actually tried, I misspelled the path, so the path did not exist, but it said it created the movie anyway at /private/var/folders/js/kn36084x6k988161dxy2fwlw0001hl/T/tmp4icsvifz/my-misspelled-path. If it created something, I have no idea where it is ahah)? Or a file picker would be even better.
- I would like to have the option to set the delay between each frame. If I play the movie on my machine, I can play it at a different speed, but if I send this to a journal, I do not think they can set the speed arbitrarily.
Thank you!
camipacifici: Ah! Revision: it followed the viewer I set for export plot! It is ok then, maybe we just need Jenn to make the UI more intuitive.
camipacifici: User says it should also show the axis, please.
pllim: > option to set the delay between each frame

I can present it as [frame rate](https://en.wikipedia.org/wiki/Frame_rate) in FPS or absolute delay in milliseconds between frames. I think the former is more common in video software and gaming, but either is possible. Please pick one.

> User says it should also show the axis, please.

Not sure what this means. It already does. Please see https://www.youtube.com/watch?v=KticlwspGV4 (also linked in original post above).

I'll see if I can address the rest in a follow up commit. Thanks for the quick response!
pllim: > User says it should also show the axis, please.

This turns out to be a bug in voila + bqplot's save_png. You can see the axis missing even when you simply save one PNG file in standalone app, so the fix for this is out of scope.
pllim: > User says it should also show the axis, please.

I don't know why but turns out when in standalone app, the axes are written out as black text on black background, rendering them invisible. If you use the "save PNG" feature to snap one frame, you can see that the axes text is black and transparent background. Is it following my OS theme (which is dark) instead of voila theme (light mode when this happened). If I start the standalone app using `--theme=dark`, I can see the axes: https://www.youtube.com/watch?v=_qwVtpycGUo
pllim: > In standalone, I could not find the movie

This is the same problem in other plugins, see https://github.com/spacetelescope/jdaviz/issues/2271, so I think fixing this is out of scope here, but I can add a note about having to provide full path in standalone app.
camipacifici: > to provide full path in standalone app

it should give an error if the path does not exist.
pllim: > it should give an error if the path does not exist.

Errr... Always error? Or just in standalone app? If the latter, is there a way to detect if we are in standalone app or not?
pllim: I think I have addressed the comments as my ability allows. I have noted follow-up issues to open for the things I cannot fix here. See "todo" in original post. Thanks!
camipacifici: Tested in standalone again. If I input just a file name, it saves as expected in the directory where I started the tool. If I propose a path (like "../filename") it goes to /private/var/folders/js/kn36084x6k988161dxy2fwlw0001hl/T/, although the path is technically valid.
If I set an invalid path, it correctly sends an error message.
pllim: > If I propose a path (like "../filename")

I am not sure how to solve this for the standalone app in a sane way. If I handle ".." what about ".././../.."? This is a slippery slope.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 3b6639f1..2e25db76 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -14,6 +14,9 @@ New Features
 Cubeviz
 ^^^^^^^
 
+- Added the ability to export cube slices to video. User will need to install
+  ``opencv-python`` separately or use ``[all]`` specifier when installing Jdaviz. [#2264]
+
 Imviz
 ^^^^^
 
@@ -77,6 +80,9 @@ Bug Fixes
 Cubeviz
 ^^^^^^^
 
+- Moment Map plugin now writes FITS file to working directory if no path provided
+  in standalone mode. [#2264]
+
 Imviz
 ^^^^^
 
diff --git a/docs/cubeviz/plugins.rst b/docs/cubeviz/plugins.rst
index abea089d..174a2079 100644
--- a/docs/cubeviz/plugins.rst
+++ b/docs/cubeviz/plugins.rst
@@ -274,3 +274,28 @@ Export Plot
 ===========
 
 This plugin allows exporting the plot in a given viewer to various image formats.
+
+.. _cubeviz-export-video:
+
+Movie
+-----
+
+.. note::
+
+    For MPEG-4, this feature needs ``opencv-python`` to be installed;
+    see [opencv-python on PyPI](https://pypi.org/project/opencv-python/).
+
+Expand the "Export to video" section, then enter the desired starting and
+ending slice indices (inclusive), the frame rate in frames per second (FPS),
+and the filename.
+If a path is not given, the file will be saved to current working
+directory. Any existing file with the same name will be silently replaced.
+
+When you are ready, click the :guilabel:`Export to MP4` button.
+The movie will be recorded at the given FPS. While recording is in progress,
+it is highly recommended that you leave the app alone until it is done.
+
+While recording, there is an option to interrupt the recording when something
+goes wrong (e.g., it is taking too long or you realized you entered the wrong inputs).
+Click on the stop icon next to the :guilabel:`Export to MP4` button to interrupt it.
+Doing so will result in no output video.
diff --git a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py
index 48c9e886..4cfb0138 100644
--- a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py
+++ b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py
@@ -137,20 +137,29 @@ class MomentMap(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelectMix
         if self.moment is None or not self.filename:  # pragma: no cover
             return
 
-        path = Path(self.filename).resolve()
-        if path.exists():
+        # Make sure file does not end up in weird places in standalone mode.
+        path = os.path.dirname(self.filename)
+        if path and not os.path.exists(path):
+            raise ValueError(f"Invalid path={path}")
+        elif (not path or path.startswith("..")) and os.environ.get("JDAVIZ_START_DIR", ""):  # noqa: E501 # pragma: no cover
+            filename = Path(os.environ["JDAVIZ_START_DIR"]) / self.filename
+        else:
+            filename = Path(self.filename).resolve()
+
+        if filename.exists():
             if overwrite:
                 # Try to delete the file
-                path.unlink()
-                if path.exists():
+                filename.unlink()
+                if filename.exists():
                     # Warn the user if the file still exists
-                    raise FileExistsError(f"Unable to delete {path}. Check user permissions.")
+                    raise FileExistsError(f"Unable to delete {filename}. Check user permissions.")
             else:
                 self.overwrite_warn = True
                 return
 
-        self.moment.write(str(path))
+        filename = str(filename)
+        self.moment.write(filename)
 
         # Let the user know where we saved the file.
         self.hub.broadcast(SnackbarMessage(
-            f"Moment map saved to {os.path.abspath(self.filename)}", sender=self, color="success"))
+            f"Moment map saved to {os.path.abspath(filename)}", sender=self, color="success"))
diff --git a/jdaviz/configs/cubeviz/plugins/slice/slice.py b/jdaviz/configs/cubeviz/plugins/slice/slice.py
index 9eb559b0..17b91269 100644
--- a/jdaviz/configs/cubeviz/plugins/slice/slice.py
+++ b/jdaviz/configs/cubeviz/plugins/slice/slice.py
@@ -122,7 +122,7 @@ class Slice(PluginTemplateMixin):
     def _on_data_added(self, msg):
         if isinstance(msg.viewer, BqplotImageView):
             if len(msg.data.shape) == 3:
-                self.max_value = msg.data.shape[-1] - 1
+                self.max_value = msg.data.shape[-1] - 1  # Same as i_end in Export Plot plugin
                 self._watch_viewer(msg.viewer, True)
                 msg.viewer.state.slices = (0, 0, int(self.slice))
 
diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.py b/jdaviz/configs/default/plugins/export_plot/export_plot.py
index 3b1522e9..4c1572ff 100644
--- a/jdaviz/configs/default/plugins/export_plot/export_plot.py
+++ b/jdaviz/configs/default/plugins/export_plot/export_plot.py
@@ -1,9 +1,23 @@
 import os
 
+from glue_jupyter.bqplot.image import BqplotImageView
+from traitlets import Any, Bool, Unicode
+
+from jdaviz.core.custom_traitlets import FloatHandleEmpty, IntHandleEmpty
+from jdaviz.core.events import AddDataMessage, SnackbarMessage
 from jdaviz.core.registries import tray_registry
 from jdaviz.core.template_mixin import PluginTemplateMixin, ViewerSelectMixin
 from jdaviz.core.user_api import PluginUserApi
 
+try:
+    import cv2
+except ImportError:
+    HAS_OPENCV = False
+else:
+    import threading
+    import time
+    HAS_OPENCV = True
+
 __all__ = ['ExportViewer']
 
 
@@ -20,16 +34,46 @@ class ExportViewer(PluginTemplateMixin, ViewerSelectMixin):
     * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show`
     * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray`
     * :meth:`save_figure`
+    * :meth:`save_movie` (Cubeviz only)
+    * `i_start` (Cubeviz only)
+    * `i_end` (Cubeviz only)
+    * `movie_fps` (Cubeviz only)
+    * `movie_filename` (Cubeviz only)
     """
     template_file = __file__, "export_plot.vue"
 
+    # For Cubeviz movie.
+    i_start = IntHandleEmpty(0).tag(sync=True)
+    i_end = IntHandleEmpty(0).tag(sync=True)
+    movie_fps = FloatHandleEmpty(5.0).tag(sync=True)
+    movie_filename = Any("mymovie.mp4").tag(sync=True)
+    movie_msg = Unicode("").tag(sync=True)
+    movie_recording = Bool(False).tag(sync=True)
+    movie_interrupt = Bool(False).tag(sync=True)
+
     @property
     def user_api(self):
+        if self.config == "cubeviz":
+            return PluginUserApi(self, expose=('viewer', 'save_figure', 'save_movie', 'i_start',
+                                               'i_end', 'movie_fps', 'movie_filename'))
         return PluginUserApi(self, expose=('viewer', 'save_figure'))
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
+        if self.config == "cubeviz":
+            if HAS_OPENCV:
+                self.session.hub.subscribe(self, AddDataMessage, handler=self._on_cubeviz_data_added)  # noqa: E501
+            else:
+                # NOTE: HTML tags do not work here.
+                self.movie_msg = 'Please install opencv-python to use this feature.'
+
+    def _on_cubeviz_data_added(self, msg):
+        # NOTE: This needs revising if we allow loading more than one cube.
+        if isinstance(msg.viewer, BqplotImageView):
+            if len(msg.data.shape) == 3:
+                self.i_end = msg.data.shape[-1] - 1  # Same as max_value in Slice plugin
+
     def save_figure(self, filename=None, filetype=None):
         """
         Save the figure to an image with a provided filename or through an interactive save dialog.
@@ -74,3 +118,184 @@ class ExportViewer(PluginTemplateMixin, ViewerSelectMixin):
         the bqplot.Figure save methods.
         """
         self.save_figure(filetype=filetype)
+
+    def _save_movie(self, i_start, i_end, fps, filename, rm_temp_files):
+        # NOTE: All the stuff here has to be in the same thread but
+        #       separate from main app thread to work.
+
+        viewer = self.viewer.selected_obj
+        slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj
+        orig_slice = slice_plg.slice
+        temp_png_files = []
+        i = i_start
+        video = None
+
+        # TODO: Expose to users?
+        i_step = 1  # Need n_frames check if we allow tweaking
+
+        try:
+            self.movie_recording = True
+
+            while i <= i_end:
+                if self.movie_interrupt:
+                    break
+
+                slice_plg._on_slider_updated({'new': i})
+                cur_pngfile = f"._cubeviz_movie_frame_{i}.png"
+                self.save_figure(filename=cur_pngfile, filetype="png")
+                temp_png_files.append(cur_pngfile)
+                i += i_step
+
+                # Wait for the roundtrip to the frontend to complete.
+                while viewer.figure._upload_png_callback is not None:
+                    time.sleep(0.05)
+
+            if not self.movie_interrupt:
+                # Grab frame size.
+                frame_shape = cv2.imread(temp_png_files[0]).shape
+                frame_size = (frame_shape[1], frame_shape[0])
+
+                video = cv2.VideoWriter(filename, cv2.VideoWriter_fourcc(*'mp4v'), fps, frame_size, True)  # noqa: E501
+                for cur_pngfile in temp_png_files:
+                    video.write(cv2.imread(cur_pngfile))
+        finally:
+            cv2.destroyAllWindows()
+            if video:
+                video.release()
+            slice_plg._on_slider_updated({'new': orig_slice})
+            self.movie_recording = False
+
+        if rm_temp_files or self.movie_interrupt:
+            for cur_pngfile in temp_png_files:
+                if os.path.exists(cur_pngfile):
+                    os.remove(cur_pngfile)
+
+        if self.movie_interrupt:
+            if os.path.exists(filename):
+                os.remove(filename)
+            self.movie_interrupt = False
+
+    def save_movie(self, i_start=None, i_end=None, fps=None, filename=None, filetype=None,
+                   rm_temp_files=True):
+        """Save selected slices as a movie.
+
+        This method creates a PNG file per frame (``._cubeviz_movie_frame_<n>.png``)
+        in the working directory before stitching all the frames into a movie.
+        Please make sure you have sufficient memory for this operation.
+        PNG files are deleted after the movie is created unless otherwise specified.
+        If another PNG file with the same name already exists, it will be silently replaced.
+
+        Parameters
+        ----------
+        i_start, i_end : int or `None`
+            Slices to record; each slice will be a frame in the movie.
+            If not given, it is obtained from plugin inputs.
+            Unlike Python indexing, ``i_end`` is inclusive.
+            Wrapping and reverse indexing are not supported.
+
+        fps : float or `None`
+            Frame rate in frames per second (FPS).
+            If not given, it is obtained from plugin inputs.
+
+        filename : str or `None`
+            Filename for the movie to be recorded. Include path if necessary.
+            If not given, it is obtained from plugin inputs.
+            If another file with the same name already exists, it will be silently replaced.
+
+        filetype : {'mp4', `None`}
+            Currently only MPEG-4 is supported. This keyword is reserved for future support
+            of other format(s).
+
+        rm_temp_files : bool
+            Remove temporary PNG files after movie creation. Default is `True`.
+
+        Returns
+        -------
+        out_filename : str
+            The absolute path to the actual output file.
+
+        """
+        if self.config != "cubeviz":
+            raise NotImplementedError(f"save_movie is not available for config={self.config}")
+
+        if not HAS_OPENCV:
+            raise ImportError("Please install opencv-python to save cube as movie.")
+
+        if filetype is None:
+            if filename is not None and '.' in filename:
+                filetype = filename.split('.')[-1]
+            else:
+                # default to MPEG-4
+                filetype = "mp4"
+
+        if filetype != "mp4":
+            raise NotImplementedError(f"filetype={filetype} not supported")
+
+        viewer = self.viewer.selected_obj
+        if not isinstance(viewer, BqplotImageView):  # Profile viewer in glue-jupyter cannot do this
+            raise TypeError(f"Movie for {viewer.__class__.__name__} is not supported.")
+        if viewer.shape is None:
+            raise ValueError("Selected viewer has no display shape.")
+
+        if fps is None:
+            fps = float(self.movie_fps)
+        if fps <= 0:
+            raise ValueError("Invalid frame rate, must be positive non-zero value.")
+
+        if filename is None:
+            if self.movie_filename:
+                filename = self.movie_filename
+            else:
+                raise ValueError("Invalid filename.")
+
+        # Make sure file does not end up in weird places in standalone mode.
+        path = os.path.dirname(filename)
+        if path and not os.path.exists(path):
+            raise ValueError(f"Invalid path={path}")
+        elif (not path or path.startswith("..")) and os.environ.get("JDAVIZ_START_DIR", ""):  # noqa: E501 # pragma: no cover
+            filename = os.path.join(os.environ["JDAVIZ_START_DIR"], filename)
+
+        if i_start is None:
+            i_start = int(self.i_start)
+
+        if i_end is None:
+            i_end = int(self.i_end)
+
+        # No wrapping. Forward only.
+        slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj
+        if i_start < 0:  # pragma: no cover
+            i_start = 0
+        if i_end > slice_plg.max_value:  # pragma: no cover
+            i_end = slice_plg.max_value
+        if i_end <= i_start:
+            raise ValueError(f"No frames to write: i_start={i_start}, i_end={i_end}")
+
+        threading.Thread(
+            target=lambda: self._save_movie(i_start, i_end, fps, filename, rm_temp_files)
+        ).start()
+
+        return os.path.abspath(filename)
+
+    def vue_save_movie(self, filetype):  # pragma: no cover
+        """
+        Callback for save movie events in the front end viewer toolbars. Uses
+        the bqplot.Figure save methods.
+        """
+        try:
+            filename = self.save_movie(filetype=filetype)
+        except Exception as err:  # pragma: no cover
+            self.hub.broadcast(SnackbarMessage(
+                f"Error saving {self.movie_filename}: {err!r}", sender=self, color="error"))
+        else:
+            # Let the user know where we saved the file.
+            # NOTE: Because of threading, this will be emitted even as movie as recording.
+            self.hub.broadcast(SnackbarMessage(
+                f"Movie being saved to {filename} for slices {self.i_start} to {self.i_end}, "
+                f"inclusive, at {self.movie_fps} FPS.",
+                sender=self, color="success"))
+
+    def vue_interrupt_recording(self, *args):  # pragma: no cover
+        self.movie_interrupt = True
+        self.hub.broadcast(SnackbarMessage(
+            f"Movie recording interrupted by user, {self.movie_filename} will be deleted.",
+            sender=self, color="warning"))
diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.vue b/jdaviz/configs/default/plugins/export_plot/export_plot.vue
index 06524293..67408e44 100644
--- a/jdaviz/configs/default/plugins/export_plot/export_plot.vue
+++ b/jdaviz/configs/default/plugins/export_plot/export_plot.vue
@@ -17,6 +17,7 @@
         <v-btn
          color="primary"
          @click="() => save_figure('png')"
+         :disabled="movie_recording"
         >
          Export to PNG
         </v-btn>
@@ -25,11 +26,110 @@
         <v-btn
          color="primary"
          @click="() => save_figure('svg')"
+         :disabled="movie_recording"
         >
          Export to SVG
         </v-btn>
        </v-list-item>
       </v-list>
+
+      <v-row v-if="config==='cubeviz' && viewer_selected!=='spectrum-viewer'">
+        <v-expansion-panels accordion>
+          <v-expansion-panel>
+            <v-expansion-panel-header v-slot="{ open }">
+              <span style="padding: 6px">Export to Video</span>
+            </v-expansion-panel-header>
+            <v-expansion-panel-content>
+              <v-row v-if="movie_msg!==''">
+                <span class="v-messages v-messages__message text--secondary" style="color: red !important">
+                    {{ movie_msg }}
+                </span>
+              </v-row>
+              <v-row v-if="movie_msg===''" class="row-no-outside-padding row-min-bottom-padding">
+                <v-col>
+                  <v-text-field
+                    v-model.number="i_start"
+                    class="mt-0 pt-0"
+                    type="number"
+                    :rules="[() => i_start>=0 || 'Must be at least zero.']"
+                    label="Start"
+                    hint="Start Slice"
+                    persistent-hint
+                  ></v-text-field>
+                </v-col>
+                <v-col>
+                  <v-text-field
+                    v-model.number="i_end"
+                    class="mt-0 pt-0"
+                    type="number"
+                    :rules="[() => i_end>i_start || 'Must be larger than Start Slice.']"
+                    label="End"
+                    hint="End Slice"
+                     persistent-hint
+                  ></v-text-field>
+                </v-col>
+              </v-row>
+              <v-row v-if="movie_msg===''" class="row-no-outside-padding row-min-bottom-padding">
+                <v-col>
+                  <v-text-field
+                    v-model.number="movie_fps"
+                    class="mt-0 pt-0"
+                    type="number"
+                    :rules="[() => movie_fps>0 || 'Must be positive.']"
+                    label="FPS"
+                    hint="Frame rate"
+                     persistent-hint
+                  ></v-text-field>
+                </v-col>
+              </v-row>
+              <v-row v-if="movie_msg===''" class="row-no-outside-padding row-min-bottom-padding">
+                <v-col>
+                  <v-text-field
+                    v-model="movie_filename"
+                    class="mt-0 pt-0"
+                    :rules="[() => movie_filename!=='' || 'Must provide filename.']"
+                    label="Filename"
+                    hint="Movie filename"
+                     persistent-hint
+                  ></v-text-field>
+                </v-col>
+              </v-row>
+              <v-row v-if="movie_msg===''" justify='end'>
+                <v-tooltip top>
+                  <template v-slot:activator="{ on, attrs }">
+                    <v-btn
+                     color="primary"
+                     @click="() => save_movie('mp4')"
+                     v-bind="attrs"
+                     v-on="on"
+                     :disabled="movie_recording"
+                    >
+                     Export to MP4
+                    </v-btn>
+                  </template>
+                  <span>Start movie recording</span>
+                </v-tooltip>
+                <div v-if="movie_recording">
+                  <v-tooltip top>
+                    <template v-slot:activator="{ on, attrs }">
+                      <v-btn
+                       color="primary"
+                       icon
+                       @click="interrupt_recording"
+                       v-bind="attrs"
+                       v-on="on"
+                       :disabled="!movie_recording">
+                        <v-icon>stop</v-icon>
+                      </v-btn>
+                    </template>
+                    <span>Interrupt recording and delete movie file</span>
+                  </v-tooltip>
+                </div>
+              </v-row>
+            </v-expansion-panel-content>
+          </v-expansion-panel>
+        </v-expansion-panels>
+      </v-row>
     </div>
 
   </j-tray-plugin>
diff --git a/notebooks/concepts/cubeviz_ndarray_gif.ipynb b/notebooks/concepts/cubeviz_ndarray_gif.ipynb
index f8dfc864..2ea04327 100644
--- a/notebooks/concepts/cubeviz_ndarray_gif.ipynb
+++ b/notebooks/concepts/cubeviz_ndarray_gif.ipynb
@@ -17,7 +17,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "0adf0be6",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "from jdaviz import Cubeviz"
@@ -141,7 +143,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "fab04442",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "filename = 'baby_shark.gif'"
@@ -151,7 +155,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "c89c8d5f",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "cubeviz2 = Cubeviz()"
@@ -161,7 +167,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "137fdde0",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "cubeviz2.load_data(filename)"
@@ -172,17 +180,168 @@
    "execution_count": null,
    "id": "ab4eca75",
    "metadata": {
-    "scrolled": false
+    "tags": []
    },
    "outputs": [],
    "source": [
     "cubeviz2.show()"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "b288fe1c",
+   "metadata": {},
+   "source": [
+    "### But what about roundtripping?\n",
+    "\n",
+    "Well, sort of..."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "22902812",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "export_plg = cubeviz2.plugins[\"Export Plot\"]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b78bbf5a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Or you can use the GUI.\n",
+    "export_plg.save_movie(0, 131, fps=10, filename=\"baby_shark_roundtrip.mp4\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4c00ffed",
+   "metadata": {},
+   "source": [
+    "See the rendered movie at https://www.youtube.com/watch?v=n8czt1ZQUNk"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "baec2fe1",
+   "metadata": {},
+   "source": [
+    "### More shark!\n",
+    "\n",
+    "Ellie says “more shark”! See the demo at https://www.youtube.com/watch?v=ZTHJfSdmnBA"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c68bed4b",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "cubeviz2.app.add_data_to_viewer(\"uncert-viewer\", \"baby_shark\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1cd9ecd1",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "plot_plg = cubeviz2.plugins[\"Plot Options\"]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e34310f2",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "plot_plg.viewer = \"flux-viewer\"\n",
+    "plot_plg.image_color_mode = \"Monochromatic\"\n",
+    "plot_plg.image_color = \"Red\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "252fe8be",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "plot_plg.viewer = \"uncert-viewer\"\n",
+    "plot_plg.image_color_mode = \"Monochromatic\"\n",
+    "plot_plg.image_color = \"Blue\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "82111003",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "slice_plg = cubeviz2.plugins[\"Slice\"]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "41de986e-c051-4021-a51e-bbdb6b478505",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "slice_plg.slice = 0"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7cf924c0",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "# More shark!\n",
+    "slice_plg._obj.vue_play_start_stop()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b212b4fd",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "# Run again to stop.\n",
+    "slice_plg._obj.vue_play_start_stop()"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "4d1af33f",
+   "id": "03f27eb1-531f-4509-ba7c-e5a3661a58eb",
    "metadata": {},
    "outputs": [],
    "source": []
@@ -204,7 +363,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.4"
+   "version": "3.11.0"
   }
  },
  "nbformat": 4,
diff --git a/pyproject.toml b/pyproject.toml
index 08a72273..246618af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,6 +66,9 @@ mosviz = "jdaviz.configs.mosviz"
 imviz = "jdaviz.configs.imviz"
 
 [project.optional-dependencies]
+all = [
+    "opencv-python",
+]
 test = [
     "pytest",
     "pytest-astropy",
diff --git a/tox.ini b/tox.ini
index d212680c..2ee43453 100644
--- a/tox.ini
+++ b/tox.ini
@@ -65,8 +65,7 @@ deps =
 extras =
     test
     romandeps: roman
-    # Uncomment when we have all again in setup.cfg
-    #alldeps: all
+    alldeps: all
 
 commands =
     devdeps: pip install -U -i https://pypi.anaconda.org/astropy/simple astropy --pre


Test patch:
diff --git a/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py b/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py
index 4408d6f0..8ad373d3 100644
--- a/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py
+++ b/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py
@@ -131,3 +131,7 @@ def test_write_momentmap(cubeviz_helper, spectrum1d_cube, tmp_path):
         sky = w.pixel_to_world(0, 0)
         assert_allclose(sky.ra.deg, 204.9998877673)
         assert_allclose(sky.dec.deg, 27.0001)
+
+    plugin._obj.filename = "fake_path/test_file.fits"
+    with pytest.raises(ValueError, match="Invalid path"):
+        plugin._obj.vue_save_as_fits()
diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py
new file mode 100644
index 00000000..31426e18
--- /dev/null
+++ b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py
@@ -0,0 +1,93 @@
+import os
+
+import pytest
+
+from jdaviz.configs.default.plugins.export_plot.export_plot import HAS_OPENCV
+
+
+# TODO: Remove skip when https://github.com/bqplot/bqplot/pull/1397/files#r726500097 is resolved.
+@pytest.mark.skip(reason="Cannot test due to async JS callback")
+# @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
+def test_export_movie(cubeviz_helper, spectrum1d_cube, tmp_path):
+    orig_path = os.getcwd()
+    os.chdir(tmp_path)
+    try:
+        cubeviz_helper.load_data(spectrum1d_cube, data_label="test")
+        plugin = cubeviz_helper.plugins["Export Plot"]
+        assert plugin.i_start == 0
+        assert plugin.i_end == 1
+        assert plugin.movie_filename == "mymovie.mp4"
+
+        plugin._obj.vue_save_movie("mp4")
+        assert os.path.isfile("mymovie.mp4"), tmp_path
+    finally:
+        os.chdir(orig_path)
+
+
+@pytest.mark.skipif(HAS_OPENCV, reason="opencv-python is installed")
+def test_no_opencv(cubeviz_helper, spectrum1d_cube):
+    cubeviz_helper.load_data(spectrum1d_cube, data_label="test")
+    plugin = cubeviz_helper.plugins["Export Plot"]
+    assert plugin._obj.movie_msg != ""
+    with pytest.raises(ImportError, match="Please install opencv-python"):
+        plugin.save_movie()
+
+
+@pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
+def test_export_movie_not_cubeviz(imviz_helper):
+    plugin = imviz_helper.plugins["Export Plot"]
+
+    with pytest.raises(NotImplementedError, match="save_movie is not available for config"):
+        plugin._obj.save_movie()
+
+    # Also not available via plugin public API.
+    with pytest.raises(AttributeError):
+        plugin.save_movie()
+
+
+@pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
+def test_export_movie_cubeviz_exceptions(cubeviz_helper, spectrum1d_cube):
+    cubeviz_helper.load_data(spectrum1d_cube, data_label="test")
+    cubeviz_helper.default_viewer.shape = (100, 100)
+    cubeviz_helper.app.get_viewer("uncert-viewer").shape = (100, 100)
+    plugin = cubeviz_helper.plugins["Export Plot"]
+    assert plugin._obj.movie_msg == ""
+    assert plugin.i_start == 0
+    assert plugin.i_end == 1
+    assert plugin.movie_filename == "mymovie.mp4"
+
+    with pytest.raises(NotImplementedError, match="filetype"):
+        plugin.save_movie(filetype="gif")
+
+    with pytest.raises(NotImplementedError, match="filetype"):
+        plugin.save_movie(filename="mymovie.gif", filetype=None)
+
+    with pytest.raises(ValueError, match="No frames to write"):
+        plugin.save_movie(i_start=0, i_end=0)
+
+    with pytest.raises(ValueError, match="Invalid frame rate"):
+        plugin.save_movie(fps=0)
+
+    plugin.movie_filename = "fake_path/mymovie.mp4"
+    with pytest.raises(ValueError, match="Invalid path"):
+        plugin.save_movie()
+
+    plugin.movie_filename = "mymovie.mp4"
+    plugin.viewer = 'spectrum-viewer'
+    with pytest.raises(TypeError, match=r"Movie for.*is not supported"):
+        plugin.save_movie()
+
+    plugin.movie_filename = ""
+    plugin.viewer = 'uncert-viewer'
+    with pytest.raises(ValueError, match="Invalid filename"):
+        plugin.save_movie()
+
+
+@pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
+def test_export_movie_cubeviz_empty(cubeviz_helper):
+    plugin = cubeviz_helper.plugins["Export Plot"]
+    assert plugin.i_start == 0
+    assert plugin.i_end == 0
+
+    with pytest.raises(ValueError, match="Selected viewer has no display shape"):
+        plugin.save_movie(i_start=0, i_end=1)
diff --git a/jdaviz/conftest.py b/jdaviz/conftest.py
index d56ecf77..3f5b9d7f 100644
--- a/jdaviz/conftest.py
+++ b/jdaviz/conftest.py
@@ -180,7 +180,7 @@ def multi_order_spectrum_list(spectrum1d, spectral_orders=10):
 
 
 def _create_spectrum1d_cube_with_fluxunit(fluxunit=u.Jy, shape=(2, 2, 4), with_uncerts=False):
-
+    # nz=2 nx=2 ny=4
     flux = np.arange(np.prod(shape)).reshape(shape) * fluxunit
     wcs_dict = {"CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", "CTYPE3": "WAVE-LOG",
                 "CRVAL1": 205, "CRVAL2": 27, "CRVAL3": 4.622e-7,