diff --git a/src/python/boytacean/__init__.py b/src/python/boytacean/__init__.py
index 033b1adcc958d327f9839bf726449cf930b7d3d6..299682f4ce9e026b700a5f8adc958603b39b381c 100644
--- a/src/python/boytacean/__init__.py
+++ b/src/python/boytacean/__init__.py
@@ -1,11 +1,12 @@
-from .gb import (
+from .gb import GameBoyMode, GameBoy
+from .palettes import PALETTES
+from .video import VideoCapture
+
+from .boytacean import (
     DISPLAY_WIDTH,
     DISPLAY_HEIGHT,
     CPU_FREQ,
     VISUAL_FREQ,
     LCD_CYCLES,
-    GameBoy,
-    GameBoyMode,
-    GameBoyRust,
+    GameBoy as GameBoyRust,
 )
-from .palettes import PALETTES
diff --git a/src/python/boytacean/gb.py b/src/python/boytacean/gb.py
index bc03dc8c27c69e92700fa13589d56f707f1bf931..36e6c8629079ac664158a6f6abc9f94cd614be93 100644
--- a/src/python/boytacean/gb.py
+++ b/src/python/boytacean/gb.py
@@ -1,21 +1,15 @@
 from enum import Enum
-from glob import glob
-from math import ceil
-from shutil import rmtree
-from tempfile import mkdtemp
 from contextlib import contextmanager
 from typing import Any, Iterable, Union
 
 from PIL.Image import Image, frombytes
 
 from .palettes import PALETTES
+from .video import VideoCapture
 
 from .boytacean import (
     DISPLAY_WIDTH,
     DISPLAY_HEIGHT,
-    CPU_FREQ,
-    VISUAL_FREQ,
-    LCD_CYCLES,
     GameBoy as GameBoyRust,
 )
 
@@ -28,9 +22,7 @@ class GameBoyMode(Enum):
 
 class GameBoy:
     _frame_index: int = 0
-    _start_frame: Union[int, None]
-    _frame_gap: int
-    _capture_temp_dir: Union[str, None]
+    _video: Union[VideoCapture, None] = None
 
     def __init__(
         self,
@@ -44,9 +36,7 @@ class GameBoy:
     ):
         super().__init__()
         self._frame_index = 0
-        self._next_frame = None
-        self._frame_gap = VISUAL_FREQ
-        self._capture_temp_dir = None
+        self._video = None
         self._system = GameBoyRust(mode.value)
         self._system.set_ppu_enabled(ppu_enabled)
         self._system.set_apu_enabled(apu_enabled)
@@ -99,42 +89,22 @@ This is a [Game Boy](https://en.wikipedia.org/wiki/Game_Boy) emulator built usin
         image = frombytes("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), frame_buffer, "raw")
         return image
 
-    def save_image(self, filename: str, format: str = "PNG"):
+    def save_image(self, filename: str, format: str = "png"):
         image = self.image()
-        image.save(filename, format=format)
+        image.save(f"{filename}.{format.lower()}", format=format)
 
     def video(
         self,
-        encoder="avc1",
         display=True,
-        file_name="output.mp4",
-        frame_glob="frame_*.png",
     ) -> Any:
-        from cv2 import VideoWriter, VideoWriter_fourcc, imread
-        from IPython.display import Video, display as _display
+        from IPython.display import display as _display
 
-        image_paths = glob(f"{self._capture_temp_dir}/{frame_glob}")
-        video_path = f"{self._capture_temp_dir}/{file_name}"
-
-        encoder = VideoWriter(
-            video_path,
-            VideoWriter_fourcc(*encoder),
-            VISUAL_FREQ / self._frame_gap,
-            (DISPLAY_WIDTH, DISPLAY_HEIGHT),
-        )
-
-        try:
-            for image_file in sorted(image_paths):
-                image = imread(image_file)
-                encoder.write(image)
-        finally:
-            encoder.release()
-
-        video = Video(video_path, embed=True, html_attributes="controls loop autoplay")
+        if self._video == None:
+            raise RuntimeError("Not capturing a video")
 
+        video = self._video.build()
         if display:
             _display(video)
-
         return video
 
     def set_palette(self, name: str):
@@ -198,27 +168,54 @@ This is a [Game Boy](https://en.wikipedia.org/wiki/Game_Boy) emulator built usin
         return PALETTES.keys()
 
     @contextmanager
-    def video_capture(self, fps=5):
-        self._start_capture(fps=fps)
+    def video_capture(
+        self,
+        video_format="avc1",
+        video_extension="mp4",
+        video_name="output",
+        fps=5,
+        frame_format="png",
+    ):
+        self._start_capture(
+            video_format=video_format,
+            video_extension=video_extension,
+            video_name=video_name,
+            fps=fps,
+            frame_format=frame_format,
+        )
         try:
             yield
         finally:
-            self.video()
-            self._stop_capture()
+            try:
+                self.video()
+            finally:
+                self._stop_capture()
 
     def _on_next_frame(self):
-        if self._next_frame != None and self._frame_index >= self._next_frame:
-            self._next_frame = self._next_frame + self._frame_gap
-            self.save_image(
-                f"{self._capture_temp_dir}/frame_{self._frame_index:08d}.png"
-            )
+        if self._video != None and self._video.should_capture(self._frame_index):
+            self._video.save_frame(self.image(), self._frame_index)
+            self._video.compute_next(self._frame_index)
 
-    def _start_capture(self, fps=5):
-        self._next_frame = self._frame_index + self._frame_gap
-        self._frame_gap = ceil(VISUAL_FREQ / fps)
-        self._capture_temp_dir = mkdtemp()
+    def _start_capture(
+        self,
+        video_format="avc1",
+        video_extension="mp4",
+        video_name="output",
+        fps=5,
+        frame_format="png",
+    ):
+        if self._video != None:
+            raise RuntimeError("Already capturing a video")
+        self._video = VideoCapture(
+            start_frame=self._frame_index,
+            video_format=video_format,
+            video_extension=video_extension,
+            video_name=video_name,
+            fps=fps,
+            frame_format=frame_format,
+        )
 
     def _stop_capture(self):
-        self._next_frame = None
-        if self._capture_temp_dir:
-            rmtree(self._capture_temp_dir)
+        if self._video:
+            self._video.cleanup()
+        self._video = None
diff --git a/src/python/boytacean/video.py b/src/python/boytacean/video.py
new file mode 100644
index 0000000000000000000000000000000000000000..306ba6a27951570044ff0b2b1443adedd2a15274
--- /dev/null
+++ b/src/python/boytacean/video.py
@@ -0,0 +1,124 @@
+from glob import glob
+from math import ceil
+from shutil import rmtree
+from typing import Any, Sequence, Union
+from tempfile import mkdtemp
+
+from PIL.Image import Image
+
+from .boytacean import (
+    DISPLAY_WIDTH,
+    DISPLAY_HEIGHT,
+    VISUAL_FREQ,
+)
+
+FORMATS = {
+    "mp4": ["avc1", "mp4", "h264", "hev1"],
+    "webm": ["vp8", "vp9"],
+    "mkv": ["avc1", "mp4", "h264", "hev1"],
+}
+
+
+class VideoCapture:
+    _start_frame: Union[int, None] = None
+    _next_frame: Union[int, None] = None
+    _video_format: str = "avc1"
+    _video_extension: str = "mp4"
+    _video_name: str = "output"
+    _frame_gap: int = 60
+    _frame_format: str = "png"
+    _frame_prefix: str = "frame_"
+    _capture_temp_dir: Union[str, None] = None
+
+    def __init__(
+        self,
+        start_frame=0,
+        video_format="avc1",
+        video_extension="mp4",
+        video_name="output",
+        fps=5,
+        frame_format="png",
+    ):
+        super().__init__()
+        self._start_frame = start_frame
+        self._video_format = video_format
+        self._video_extension = video_extension
+        self._video_name = video_name
+        self._frame_format = frame_format
+        self._frame_gap = ceil(VISUAL_FREQ / fps)
+        self._next_frame = start_frame + self._frame_gap
+        self._capture_temp_dir = mkdtemp()
+
+    @classmethod
+    def formats(cls, extension="mp4") -> Sequence[str]:
+        return FORMATS.get(extension, [])
+
+    def should_capture(self, frame_index) -> bool:
+        return self._start_frame == None or frame_index >= self._next_frame
+
+    def compute_next(self, frame_index):
+        self._next_frame = frame_index + self._frame_gap
+
+    def frame_path(self, frame_index) -> str:
+        return f"{self._capture_temp_dir}/{self._frame_prefix}{frame_index:08d}.{self.frame_format_l}"
+
+    def cleanup(self):
+        if self._capture_temp_dir:
+            rmtree(self._capture_temp_dir)
+
+    def save_frame(self, frame: Image, frame_index: int):
+        frame.save(self.frame_path(frame_index), format=self.frame_format)
+
+    def build(self) -> Any:
+        from cv2 import VideoWriter, VideoWriter_fourcc, imread
+        from IPython.display import Video
+
+        if not self._capture_temp_dir:
+            raise RuntimeError("Not capturing a video")
+
+        image_paths = glob(f"{self.frames_glob}")
+        video_path = f"{self._capture_temp_dir}/{self.video_filename}"
+
+        encoder = VideoWriter(
+            video_path,
+            VideoWriter_fourcc(*self.video_format_fourcc),
+            self.fps,
+            (DISPLAY_WIDTH, DISPLAY_HEIGHT),
+        )
+
+        try:
+            for image_file in sorted(image_paths):
+                image = imread(image_file)
+                encoder.write(image)
+        finally:
+            encoder.release()
+
+        return Video(video_path, embed=True, html_attributes="controls loop autoplay")
+
+    @property
+    def fps(self) -> float:
+        return VISUAL_FREQ / self._frame_gap
+
+    @property
+    def video_filename(self) -> str:
+        return f"{self._video_name}.{self._video_extension}"
+
+    @property
+    def video_format_l(self) -> str:
+        return self._video_format.lower()
+
+    @property
+    def video_format_fourcc(self) -> str:
+        return self.video_format_l.ljust(4, "0")
+
+    @property
+    def frame_format(self) -> str:
+        return self._frame_format
+
+    @property
+    def frame_format_l(self) -> str:
+        return self._frame_format.lower()
+
+    @property
+    def frames_glob(self) -> str:
+        return f"{self._capture_temp_dir}/{self._frame_prefix}*.{self.frame_format_l}"