Skip to content

capturegraph.data.load.types.video #

VideoFile - Simple container for video path with metadata#

A simple dataclass that bundles a video path with its metadata.

Example
import capturegraph.data as cg

# Wrap a video path with its metadata
video = cg.VideoFile("recording.mov")
print(video.path)       # Path("recording.mov")
print(video.duration)   # 15.5 (seconds)
print(video.width)      # 1920
print(video.height)     # 1080

# Generate a thumbnail for Altair tooltips
thumbnail = video.tooltip(64)  # base64 data URL
Notes
  • Uses OpenCV for video metadata extraction
  • Supports MOV and MP4 formats (as saved by iOS PVideo)
  • Lazy-loads metadata on first access for efficiency

VideoFile dataclass #

A video file with its path and metadata.

A simple container that bundles a Path with video metadata. Use VideoFile() to create one from a file path.

Attributes:

Name Type Description
path Path

The path to the video file.

Properties (lazy-loaded): video_type: Format type ("mov" or "mp4") duration: Video duration in seconds width: Video width in pixels height: Video height in pixels fps: Frames per second frame_count: Total number of frames

Example
video = VideoFile("recording.mov")
video.path        # PosixPath('recording.mov')
video.video_type  # 'mov'
video.duration    # 15.5
video.width       # 1920
video.height      # 1080

# Extract a frame at a specific time
frame = video.frame_at(5.0)  # PIL.Image at 5 seconds

# Generate thumbnail for Altair
tooltip = video.tooltip(64)  # 'data:image/png;base64,...'
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
@dataclass
class VideoFile:
    """A video file with its path and metadata.

    A simple container that bundles a Path with video metadata.
    Use `VideoFile()` to create one from a file path.

    Attributes:
        path: The path to the video file.

    Properties (lazy-loaded):
        video_type: Format type ("mov" or "mp4")
        duration: Video duration in seconds
        width: Video width in pixels
        height: Video height in pixels
        fps: Frames per second
        frame_count: Total number of frames

    Example:
        ```python
        video = VideoFile("recording.mov")
        video.path        # PosixPath('recording.mov')
        video.video_type  # 'mov'
        video.duration    # 15.5
        video.width       # 1920
        video.height      # 1080

        # Extract a frame at a specific time
        frame = video.frame_at(5.0)  # PIL.Image at 5 seconds

        # Generate thumbnail for Altair
        tooltip = video.tooltip(64)  # 'data:image/png;base64,...'
        ```
    """

    path: Path
    metadata: Dict

    def __init__(self, path: Path | str):
        """Create a VideoFile from a path.

        Args:
            path: Path to the video file.
        """
        self.path = Path(path).resolve()
        self.metadata = load_video_metadata(self.path)

    @property
    def video_type(self) -> str:
        """Return the video type based on file extension.

        Returns:
            One of 'mov' or 'mp4'.

        Example:
            ```python
            video = VideoFile("recording.mov")
            video.video_type  # 'mov'

            video2 = VideoFile("clip.mp4")
            video2.video_type  # 'mp4'
            ```
        """
        suffix = self.path.suffix.lower()
        if suffix == ".mov":
            return "mov"
        elif suffix == ".mp4":
            return "mp4"
        else:
            return "unknown"

    def save(self, path: Path | str) -> "VideoFile":
        """Copy the video file to a new location.

        Args:
            path: Destination path for the video file.

        Returns:
            New VideoFile pointing to the copied file.

        Example:
            ```python
            video = VideoFile("recording.mov")
            copy = video.save(Path("output/recording.mov"))
            ```
        """
        path = Path(path).resolve()
        path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(self.path, path)
        return VideoFile(path)

    def frame_at(self, time_seconds: float = 0.0) -> Image.Image:
        """Extract a frame at a specific time and return as PIL Image.

        Args:
            time_seconds: Time in seconds to extract frame from.
                         Defaults to 0.0 (first frame).

        Returns:
            PIL.Image.Image: The extracted frame.

        Example:
            ```python
            video = VideoFile("recording.mov")
            frame = video.frame_at(5.0)  # Frame at 5 seconds
            frame.save("frame.jpg")
            ```
        """
        cap = cv2.VideoCapture(str(self.path))
        try:
            if not cap.isOpened():
                raise IOError(f"Could not open video: {self.path}")

            # Seek to the desired time
            fps = cap.get(cv2.CAP_PROP_FPS)
            frame_number = int(time_seconds * fps)
            cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)

            ret, frame = cap.read()
            if not ret:
                raise IOError(
                    f"Could not read frame at {time_seconds}s from {self.path}"
                )

            # Convert BGR (OpenCV) to RGB (PIL)
            rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            return Image.fromarray(rgb_frame)
        finally:
            cap.release()

    def thumbnail(
        self,
        max_axis: int = 256,
        frame_time_seconds: float = 0.0,
    ) -> Image.Image:
        """Generate a thumbnail from the first frame.

        Args:
            max_axis: Maximum dimension (width or height) of the thumbnail.

        Returns:
            PIL.Image.Image: The thumbnail image.

        Example:
            ```python
            video = VideoFile("recording.mov")
            thumb = video.thumbnail(128)
            thumb.save("thumb.jpg")
            ```
        """
        img = self.frame_at(frame_time_seconds)

        # Calculate new size maintaining aspect ratio
        width, height = img.size
        if width > height:
            new_width = max_axis
            new_height = int(height * max_axis / width)
        else:
            new_height = max_axis
            new_width = int(width * max_axis / height)

        return img.resize(
            (new_width, new_height),
            Image.Resampling.LANCZOS,
        )

    def tooltip(
        self,
        max_axis: int = 64,
        frame_time_seconds: float = 0.0,
    ) -> str:
        """Create a base64-encoded thumbnail for Altair/Vega tooltips.

        Extracts the first frame, resizes so its largest dimension is
        `max_axis`, then encodes as a base64 PNG data URL.

        Args:
            max_axis: Maximum dimension (width or height) of the thumbnail.

        Returns:
            A data URL string like "data:image/png;base64,..." that can be
            used directly in Altair/Vega tooltip specifications.

        Example:
            ```python
            video = VideoFile("recording.mov")
            video.tooltip(64)  # 'data:image/png;base64,iVBORw0KGgo...'
            ```
        """
        img = self.thumbnail(max_axis)

        # Convert to RGB if necessary (for PNG encoding)
        if img.mode in ("RGBA", "LA", "P"):
            img = img.convert("RGB")

        # Encode to base64
        buffered = BytesIO()
        img.save(buffered, format="PNG")
        img_str = base64.b64encode(buffered.getvalue()).decode()

        return f"data:image/png;base64,{img_str}"

    def __repr__(self) -> str:
        return f"VideoFile({str(self.path)!r})"

video_type property #

Return the video type based on file extension.

Returns:

Type Description
str

One of 'mov' or 'mp4'.

Example
video = VideoFile("recording.mov")
video.video_type  # 'mov'

video2 = VideoFile("clip.mp4")
video2.video_type  # 'mp4'

__init__(path) #

Create a VideoFile from a path.

Parameters:

Name Type Description Default
path Path | str

Path to the video file.

required
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def __init__(self, path: Path | str):
    """Create a VideoFile from a path.

    Args:
        path: Path to the video file.
    """
    self.path = Path(path).resolve()
    self.metadata = load_video_metadata(self.path)

save(path) #

Copy the video file to a new location.

Parameters:

Name Type Description Default
path Path | str

Destination path for the video file.

required

Returns:

Type Description
VideoFile

New VideoFile pointing to the copied file.

Example
video = VideoFile("recording.mov")
copy = video.save(Path("output/recording.mov"))
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def save(self, path: Path | str) -> "VideoFile":
    """Copy the video file to a new location.

    Args:
        path: Destination path for the video file.

    Returns:
        New VideoFile pointing to the copied file.

    Example:
        ```python
        video = VideoFile("recording.mov")
        copy = video.save(Path("output/recording.mov"))
        ```
    """
    path = Path(path).resolve()
    path.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(self.path, path)
    return VideoFile(path)

frame_at(time_seconds=0.0) #

Extract a frame at a specific time and return as PIL Image.

Parameters:

Name Type Description Default
time_seconds float

Time in seconds to extract frame from. Defaults to 0.0 (first frame).

0.0

Returns:

Type Description
Image

PIL.Image.Image: The extracted frame.

Example
video = VideoFile("recording.mov")
frame = video.frame_at(5.0)  # Frame at 5 seconds
frame.save("frame.jpg")
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def frame_at(self, time_seconds: float = 0.0) -> Image.Image:
    """Extract a frame at a specific time and return as PIL Image.

    Args:
        time_seconds: Time in seconds to extract frame from.
                     Defaults to 0.0 (first frame).

    Returns:
        PIL.Image.Image: The extracted frame.

    Example:
        ```python
        video = VideoFile("recording.mov")
        frame = video.frame_at(5.0)  # Frame at 5 seconds
        frame.save("frame.jpg")
        ```
    """
    cap = cv2.VideoCapture(str(self.path))
    try:
        if not cap.isOpened():
            raise IOError(f"Could not open video: {self.path}")

        # Seek to the desired time
        fps = cap.get(cv2.CAP_PROP_FPS)
        frame_number = int(time_seconds * fps)
        cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)

        ret, frame = cap.read()
        if not ret:
            raise IOError(
                f"Could not read frame at {time_seconds}s from {self.path}"
            )

        # Convert BGR (OpenCV) to RGB (PIL)
        rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        return Image.fromarray(rgb_frame)
    finally:
        cap.release()

thumbnail(max_axis=256, frame_time_seconds=0.0) #

Generate a thumbnail from the first frame.

Parameters:

Name Type Description Default
max_axis int

Maximum dimension (width or height) of the thumbnail.

256

Returns:

Type Description
Image

PIL.Image.Image: The thumbnail image.

Example
video = VideoFile("recording.mov")
thumb = video.thumbnail(128)
thumb.save("thumb.jpg")
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def thumbnail(
    self,
    max_axis: int = 256,
    frame_time_seconds: float = 0.0,
) -> Image.Image:
    """Generate a thumbnail from the first frame.

    Args:
        max_axis: Maximum dimension (width or height) of the thumbnail.

    Returns:
        PIL.Image.Image: The thumbnail image.

    Example:
        ```python
        video = VideoFile("recording.mov")
        thumb = video.thumbnail(128)
        thumb.save("thumb.jpg")
        ```
    """
    img = self.frame_at(frame_time_seconds)

    # Calculate new size maintaining aspect ratio
    width, height = img.size
    if width > height:
        new_width = max_axis
        new_height = int(height * max_axis / width)
    else:
        new_height = max_axis
        new_width = int(width * max_axis / height)

    return img.resize(
        (new_width, new_height),
        Image.Resampling.LANCZOS,
    )

tooltip(max_axis=64, frame_time_seconds=0.0) #

Create a base64-encoded thumbnail for Altair/Vega tooltips.

Extracts the first frame, resizes so its largest dimension is max_axis, then encodes as a base64 PNG data URL.

Parameters:

Name Type Description Default
max_axis int

Maximum dimension (width or height) of the thumbnail.

64

Returns:

Type Description
str

A data URL string like "data:image/png;base64,..." that can be

str

used directly in Altair/Vega tooltip specifications.

Example
video = VideoFile("recording.mov")
video.tooltip(64)  # 'data:image/png;base64,iVBORw0KGgo...'
Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def tooltip(
    self,
    max_axis: int = 64,
    frame_time_seconds: float = 0.0,
) -> str:
    """Create a base64-encoded thumbnail for Altair/Vega tooltips.

    Extracts the first frame, resizes so its largest dimension is
    `max_axis`, then encodes as a base64 PNG data URL.

    Args:
        max_axis: Maximum dimension (width or height) of the thumbnail.

    Returns:
        A data URL string like "data:image/png;base64,..." that can be
        used directly in Altair/Vega tooltip specifications.

    Example:
        ```python
        video = VideoFile("recording.mov")
        video.tooltip(64)  # 'data:image/png;base64,iVBORw0KGgo...'
        ```
    """
    img = self.thumbnail(max_axis)

    # Convert to RGB if necessary (for PNG encoding)
    if img.mode in ("RGBA", "LA", "P"):
        img = img.convert("RGB")

    # Encode to base64
    buffered = BytesIO()
    img.save(buffered, format="PNG")
    img_str = base64.b64encode(buffered.getvalue()).decode()

    return f"data:image/png;base64,{img_str}"

load_video_metadata(path) #

Load video metadata using OpenCV.

Source code in capturegraph-lib/capturegraph/data/load/types/video.py
def load_video_metadata(path: Path) -> Dict[Any]:
    """Load video metadata using OpenCV."""
    cap = cv2.VideoCapture(str(path))
    try:
        if not cap.isOpened():
            raise IOError(f"Could not open video: {path}")

        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = cap.get(cv2.CAP_PROP_FPS)
        frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        duration = frame_count / fps if fps > 0 else 0

        return Dict(
            {
                "width": width,
                "height": height,
                "fps": fps,
                "frame_count": frame_count,
                "duration": duration,
            }
        )
    finally:
        cap.release()

    return Dict()