Repository: BAMWelDX/weldx

Issue:
schemas for images/videos
So far we don't have a schema representation of image or video data/files. Both images & videos share some similarities on how we might implement them so I will group them in this issue for now.

I guess the most common use cases would be

1.  adding an image/video as an external file (blob storage or external reference)
2.  adding an image/video as a raw data matrix (without compression)

To implement the first use-case we should probably add a `MIME type` field to the `file` schema to make thing easier to handle.
The second use-case is mostly suited for raw recordings, if available to input into image processing/ML applications.

Images as `file` should be straight forward, for raw data I suggest defining a suitable array format with coordinates such as `row/col` or similar.

Videos should also be easy but we should think about how to handle/read the associated timeline of the video into our data structures. A Video could then be a `file` with an additional `time` attribute.
For raw data, I guess it would make sense to have the video be a series of images with timestamps.

For both raw data we should look into which formats we might want to start with, I suggest at least `grey-scale` and `RGB` should be supported (maybe `HSL`?)

Discussion:
github-actions[bot]: ## Unit Test Results
0 files   -        1  0 suites   - 1   0s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") - 3m 5s
0 tests  - 2 158  0 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests")  - 2 158  0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 

Results for commit 2d5019ae. ± Comparison against base commit 487218cf.

marscher: Actually I wanted to open this as a draft...
CagtayFabry: thank you for getting this started @marscher 🙌 

Just a few quick notes on the ASDF side of things:
- when changing version numbers of existing schemas (like `id: "asdf://weldx.bam.de/weldx/schemas/core/file-0.1.1"` here) we have to keep the old schema file as is and add a new file for this version (create `weldx/schemas/weldx.bam.de/weldx/core/file-0.1.0.yaml`)
(we don't need to change version numbers for schema files that have not yet been released on pip/conda)
- run `devtools/scripts/update_manifest.py` after adding/removing schema files to update the current manifest
- make sure the `Converter` is associated with and set up to handle all schema versions in the `tags` property:
Is this case change the old version (only supports schema version) https://github.com/BAMWelDX/weldx/blob/a99742a1532ab977d547625cd13e6c9fb096361a/weldx/tags/core/file.py#L142-L144
to
```python
    tags = ["asdf://weldx.bam.de/weldx/schemas/core/file-0.1.*"]
    types = [ExternalFile]
```
marscher: the pims lib is not yet ready for Python-3.10
github-actions[bot]: ## Test Results
2 187 tests  +2   2 184 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") ±0   3m 30s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") +51s
       1 suites ±0          1 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0 
       1 files   ±0          2 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") +2 

For more details on these failures, see [this check](https://github.com/BAMWelDX/weldx/runs/8093536514).

Results for commit 2a68c4e1. ± Comparison against base commit 1ce3e3a2.

[test-results]:data:application/gzip;base64,H4sIAOUUDmMC/12Myw7CIBQFf6Vh7UJKpeDPGOReEmJbDI+V8d/loS1xOXNO5kWMXTCQ60BPAwnJxh0geRWt2zKO9JxFnmKoJOYf3kLSurnpcA/7/EaaMMou5bQL9N75bErWp62rFvqLNnU0K3fJyn1Ru3W1MQMZFRd6Qgpw5xcEqSRQxaVhMxiDgoJhiMAoeX8AwebLywkBAAA=

marscher: @CagtayFabry I'd like to merge this soon and post-pone some todo's in follow up issues.
CagtayFabry: > @CagtayFabry I'd like to merge this soon and post-pone some todo's in follow up issues.

Which ToDos are still open here? The ASDF examples?
marscher: > Which ToDos are still open here? The ASDF examples?

Basically, yes.
CagtayFabry: > > Which ToDos are still open here? The ASDF examples?
> 
> Basically, yes.

A simple ASDF example would be nice as those also run during the schema tests
Other than that I think it looks good for now? Is the `mime_type` ASDF pattern working?
marscher: Right. I'd generate the example simply by instanciating a media file, serialize it and put the contents into the schema file.
marscher: > Right. I'd generate the example simply by instanciating a media file, serialize it and put the contents into the schema file.

@CagtayFabry in [6a04d5e](https://github.com/BAMWelDX/weldx/pull/727/commits/6a04d5ee5b2a98fde34dea4d39b5775eae300f88) I had to use the "old" style tag syntax. Otherwise the asdf yaml parser would raise some "undefined tag handle" error:

```
E   yaml.parser.ParserError: while parsing a node
E   found undefined tag handle
E     in "<file>", line 5, column 9
```
Do you know what might be going wrong there? I was thinking that the header of the example (used ASDF standard) could be referring to an old version?

Patch:
diff --git a/.github/workflows/build_pkg.yml b/.github/workflows/build_pkg.yml
index b086278..c344497 100644
--- a/.github/workflows/build_pkg.yml
+++ b/.github/workflows/build_pkg.yml
@@ -102,7 +102,7 @@ jobs:
 
       - name: pip dist install
         run: |
-          pip install ${{ env.SDIST_FILE }}[test]
+          pip install ${{ env.SDIST_FILE }}[test,media]
 
       - name: run pytest
         run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3164859..308bcc6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## 0.6.3 (unreleased)
 
+### Added
+
+- New class to handle image sequence data and videos `weldx.util.media_file.MediaFile` \[{pull}`727`\].
+
 ### Dependencies
 
 - Pin `asdf<2.14` due to changes in the extension mechanism \[{pull}`828`\].
diff --git a/pyproject.toml b/pyproject.toml
index 2b7b741..cf2837d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,10 +65,17 @@ test = [
     "pytest-cov",
     "pytest-xdist",
     "nbval",
-  ]
+]
 vis = [
     "weldx_widgets >=0.2",
 ]
+media = [
+    "pims",
+    "av",
+    "dask-image",
+    # this is actually required by dask-image, but not listed in their requirements:
+    "tifffile",
+]
 
 # ASDF extension entry points.
 
@@ -150,24 +157,7 @@ sqlite_cache = true
 
 # MyPy per-module options:
 [[tool.mypy.overrides]]
-module = "weldx.asdf.*"
-ignore_errors = true
-
-[[tool.mypy.overrides]]
-module = "weldx.tags.*"
-ignore_errors = true
-
-[[tool.mypy.overrides]]
-module = "weldx.tests.*"
-ignore_errors = true
-
-[[tool.mypy.overrides]]
-module = "weldx.welding.groove.*"
-ignore_errors = true
-
-# TODO: remove after refactoring the geometry module
-[[tool.mypy.overrides]]
-module = "weldx.geometry.*"
+module = ["weldx.asdf.*", "weldx.tags.*", "weldx.tests.*", "weldx.welding.groove.*",  "weldx.geometry.*" ]
 ignore_errors = true
 
 # this is a workaround for an xarray related mypy bug
diff --git a/weldx/asdf/file.py b/weldx/asdf/file.py
index d564045..ff6459c 100644
--- a/weldx/asdf/file.py
+++ b/weldx/asdf/file.py
@@ -8,7 +8,7 @@ import warnings
 from collections.abc import Iterable, Mapping, MutableMapping, Set, ValuesView
 from contextlib import contextmanager
 from io import BytesIO, IOBase
-from typing import IO, Any, Dict, Hashable, Optional, Union
+from typing import IO, Any, Dict, Hashable, Optional, Union, get_args
 
 import asdf
 import numpy as np
@@ -296,13 +296,13 @@ class WeldxFile(_ProtectedViewDict):
             new_file_created = True
             self._in_memory = True
             self._close = False  # we want buffers to be usable later on.
-        elif isinstance(filename_or_file_like, types_path_like.__args__):
+        elif isinstance(filename_or_file_like, get_args(types_path_like)):
             filename_or_file_like, new_file_created = self._handle_path(
                 filename_or_file_like, mode
             )
             self._in_memory = False
             self._close = True  # close our own buffer
-        elif isinstance(filename_or_file_like, types_file_like.__args__):
+        elif isinstance(filename_or_file_like, get_args(types_file_like)):
             if isinstance(filename_or_file_like, BytesIO):
                 self._in_memory = True
             else:
diff --git a/weldx/asdf/types.py b/weldx/asdf/types.py
index 6dc5e31..bf34434 100644
--- a/weldx/asdf/types.py
+++ b/weldx/asdf/types.py
@@ -35,7 +35,7 @@ def to_yaml_tree_metadata(func):
         """Call default to_yaml_tree method and add metadata fields."""
         tree = func(self, obj, tag, ctx)
 
-        for key in [META_ATTR, USER_ATTR]:
+        for key in (META_ATTR, USER_ATTR):
             attr = getattr(obj, key, None)
             if attr:
                 tree[key] = attr
diff --git a/weldx/asdf/util.py b/weldx/asdf/util.py
index 948838f..2951752 100644
--- a/weldx/asdf/util.py
+++ b/weldx/asdf/util.py
@@ -504,7 +504,7 @@ def get_highest_tag_version(
     Raises
     ------
     ValueError
-        When the pattern matches multiple base tags in in the extension.
+        When the pattern matches multiple base tags in the extension.
 
     Examples
     --------
diff --git a/weldx/manifests/weldx-0.1.1.yaml b/weldx/manifests/weldx-0.1.1.yaml
index 6dce833..bcf6303 100644
--- a/weldx/manifests/weldx-0.1.1.yaml
+++ b/weldx/manifests/weldx-0.1.1.yaml
@@ -37,6 +37,8 @@ tags:
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/dimension-0.1.0
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/file-0.1.0
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/file-0.1.0
+- tag_uri: asdf://weldx.bam.de/weldx/tags/core/file-0.1.1
+  schema_uri: asdf://weldx.bam.de/weldx/schemas/core/file-0.1.1
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/generic_series-0.1.0
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/generic_series-0.1.0
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/generic_series_free_dimension-0.1.0
@@ -53,6 +55,8 @@ tags:
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/graph/di_node-0.1.0
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/mathematical_expression-0.1.0
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/mathematical_expression-0.1.0
+- tag_uri: asdf://weldx.bam.de/weldx/tags/core/media_file-0.1.0
+  schema_uri: asdf://weldx.bam.de/weldx/schemas/core/media_file-0.1.0
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/time_series-0.1.0
   schema_uri: asdf://weldx.bam.de/weldx/schemas/core/time_series-0.1.0
 - tag_uri: asdf://weldx.bam.de/weldx/tags/core/time_series-0.1.1
diff --git a/weldx/schemas/weldx.bam.de/weldx/core/file-0.1.1.yaml b/weldx/schemas/weldx.bam.de/weldx/core/file-0.1.1.yaml
new file mode 100644
index 0000000..d82e1b5
--- /dev/null
+++ b/weldx/schemas/weldx.bam.de/weldx/core/file-0.1.1.yaml
@@ -0,0 +1,88 @@
+%YAML 1.1
+---
+$schema: "http://stsci.edu/schemas/yaml-schema/draft-01"
+id: "asdf://weldx.bam.de/weldx/schemas/core/file-0.1.1"
+
+title: |
+  Schema for a file.
+description: |
+  This schema describes a file by compiling all its meta data. Optionally, the whole file can also be stored in the
+  binary block of an asdf file.
+
+examples:
+  -
+    - Full description of a pdf-file without its content
+    - |
+      !<asdf://weldx.bam.de/weldx/tags/core/file-0.1.1>
+        filename: file.pdf
+        suffix: pdf
+        mimetype: application/pdf
+        hostname: my_computer
+        directory: C:/Users/some_user/my_files
+        size: 90571
+        created: !<asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.0> '2020-12-09T12:51:20.653744500'
+        modified: !<asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.0> '2020-12-10T13:14:17.362481500'
+        content_hash: {algorithm: SHA-256, value: 5a6270ea5e2662c6489ee9e9c2201645e1b3cdadf0e3a621cca213a29ff4ae32}
+
+type: object
+properties:
+  filename:
+    description: |
+      The name of the file including the suffix.
+    type: string
+  suffix:
+    description: |
+      The suffix of the file.
+    type: string
+  mime:
+    description: |
+      MIME type associated with file extension.
+    type: string
+  size:
+    description: |
+      The files size in bytes.
+    type: number
+  created:
+    description: |
+      The timestamp when the file was created.
+    tag: "asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.*"
+  modified:
+    description: |
+      The timestamp when the file was modified last.
+    tag: "asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.*"
+  hostname:
+    description: |
+      The name of the host machine accessing the file.
+    type: string
+  directory:
+    description: |
+      The directory of the file as seen from the host machine.
+    type: string
+  content:
+    description: |
+      The content of the file. It is stored in the binary block of the file.
+    tag: "tag:stsci.edu:asdf/core/ndarray-1.*"
+  content_hash:
+    description: |
+      Hash data for the files content.
+    type: object
+    properties:
+      algorithm:
+        description: |
+          The utilized hashing algorithm.
+        type: string
+        enum: [MD5, SHA-256]
+      value:
+        description: |
+          The calculated hash.
+        type: string
+    required: [algorithm,  value]
+
+
+propertyOrder: [filename, suffix, mime, hostname, directory, size, created, modified, content, content_hash]
+anyOf:
+  - required: [filename, content, content_hash]
+  - required: [filename, hostname]
+
+flowStyle: block
+...
diff --git a/weldx/schemas/weldx.bam.de/weldx/core/media_file-0.1.0.yaml b/weldx/schemas/weldx.bam.de/weldx/core/media_file-0.1.0.yaml
new file mode 100644
index 0000000..9dc50d8
--- /dev/null
+++ b/weldx/schemas/weldx.bam.de/weldx/core/media_file-0.1.0.yaml
@@ -0,0 +1,73 @@
+%YAML 1.1
+---
+$schema: "http://stsci.edu/schemas/yaml-schema/draft-01"
+id: "asdf://weldx.bam.de/weldx/schemas/core/media_file-0.1.0"
+
+title: |
+  Schema for a media file (time-based and single shot).
+description: |
+  This schema describes a media file by compiling all its meta data.
+  Optionally, the whole file can also be stored in the
+  binary block of an asdf file.
+
+examples:
+  -
+    - Full description of a video file
+    - |
+      !<tag:stsci.edu:asdf/core/asdf-1.1.0>
+        file: !<asdf://weldx.bam.de/weldx/tags/core/file-0.1.1>
+          filename: 221006-180026-WID492_N3.avi
+          suffix: avi
+          hostname: localhost
+          directory: .
+          size: 15408040
+          created: !<asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.0> 2022-10-25T15:57:26.982713650
+          modified: !<asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.0> 2022-10-25T15:06:19.380294143
+          content_hash: {algorithm: SHA-256, value: 6758006cd439cdb3374105393d83198432a6e1638cce37e73afbbab850287e0f}
+          mimetype: video/x-msvideo
+        reference_time: !<asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.0> 2022-10-25T15:06:19.380294143
+        fps: !<asdf://weldx.bam.de/weldx/tags/units/quantity-0.1.0> {value: 55.0, units: !<asdf://weldx.bam.de/weldx/tags/units/units-0.1.0> 1
+            / second}
+        n_frames: 436
+        resolution: [700, 576]
+
+type: object
+properties:
+  file:
+    description: |
+      The basic file information/content.
+    tag: "asdf://weldx.bam.de/weldx/tags/core/file-0.1.1"
+    mime_type:
+      pattern: "image\/.*|video\/|application/mp4|application/mpeg4-.*"
+  data:
+    description: |
+      A DataArray containing the frames. Given in case this has been initialized from
+      in memory data.
+  reference_time:
+    description: |
+      Time when this media was recorded.
+    tag: "asdf://weldx.bam.de/weldx/tags/time/timestamp-0.1.*"
+  fps:
+    description: |
+      In case of time-dynamic media, the constant time resolution (frames per second), e.g. a floating point number.
+    tag: "asdf://weldx.bam.de/weldx/tags/units/quantity-0.1.0"
+    wx_unit: "1/s"
+  resolution:
+    description: |
+      Media resolution in pixels.
+    type: array
+    items:
+      type: integer
+      number: 2
+  n_frames:
+    description: |
+      Number of frames.
+    type: integer
+
+
+propertyOrder: [file, data, reference_time, fps]
+# TODO: in case of in memory data, we require: data, fps, resolution
+# TODO: in case of a file, we require: file, fps, resolution
+#required: [anyOf(file, data), fps, resolution]
+flowStyle: block
+...
diff --git a/weldx/tags/core/__init__.py b/weldx/tags/core/__init__.py
index 9fb7d70..e93901b 100644
--- a/weldx/tags/core/__init__.py
+++ b/weldx/tags/core/__init__.py
@@ -6,6 +6,7 @@ from . import (
     generic_series,
     geometry,
     mathematical_expression,
+    media_file,
     time_series,
     transformations,
 )
diff --git a/weldx/tags/core/file.py b/weldx/tags/core/file.py
index 19e09de..bfb6359 100644
--- a/weldx/tags/core/file.py
+++ b/weldx/tags/core/file.py
@@ -1,138 +1,14 @@
 """Contains classes for the asdf serialization of an external file."""
-
-
-import socket
 from copy import deepcopy
-from dataclasses import dataclass
-from hashlib import md5, sha256
-from pathlib import Path
-from typing import Union
 
 import numpy as np
-import pandas as pd
 
 from weldx.asdf.types import WeldxConverter
+from weldx.util.external_file import ExternalFile
 
 # Python class -------------------------------------------------------------------------
 
 
-@dataclass
-class ExternalFile:
-    """Handles the asdf serialization of external files."""
-
-    path: Union[str, Path] = None
-
-    filename: str = None
-    suffix: str = None
-    directory: str = None
-    hostname: str = None
-
-    created: pd.Timestamp = None
-    modified: pd.Timestamp = None
-    size: int = None
-
-    hashing_algorithm: str = "SHA-256"
-    hash: str = None
-    asdf_save_content: bool = False
-    buffer: bytes = None
-
-    hash_mapping = {"MD5": md5, "SHA-256": sha256}
-
-    def __post_init__(self):
-        """Initialize the internal values."""
-        if self.path is not None:
-            if not isinstance(self.path, Path):
-                self.path = Path(self.path)
-            if not self.path.is_file():
-                raise ValueError(f"File not found: {self.path.as_posix()}")
-
-            self.filename = self.path.name
-            self.suffix = "".join(self.path.suffixes)[1:]
-            self.directory = self.path.parent.absolute().as_posix()
-            if self.hostname is None:
-                self.hostname = socket.gethostname()
-
-            stat = self.path.stat()
-            self.size = stat.st_size
-            self.created = pd.Timestamp(stat.st_ctime_ns)
-            self.modified = pd.Timestamp(stat.st_mtime_ns)
-
-            self.hashing_algorithm = self.hashing_algorithm.upper()
-            if self.hashing_algorithm not in ExternalFile.hash_mapping:
-                raise ValueError(
-                    f"'{self.hashing_algorithm}' is not a supported hashing algorithm."
-                )
-
-    @staticmethod
-    def calculate_hash(
-        path_or_buffer: Union[str, Path, bytes],
-        algorithm: str,
-        buffer_size: int = 65536,
-    ) -> str:
-        """Calculate the hash of a file.
-
-        Parameters
-        ----------
-        path_or_buffer : Union[str, pathlib.Path, bytes]
-            Path of the file or buffer as bytes
-        algorithm : str
-            Name of the desired hashing algorithm
-        buffer_size : int
-            Size of the internally used buffer. The file will be read in
-            corresponding chunks. No effect when hashing from buffer.
-
-        Returns
-        -------
-        str :
-            The calculated hash
-
-        """
-        hashing_class = ExternalFile.hash_mapping[algorithm.upper()]()
-        if isinstance(path_or_buffer, bytes):
-            hashing_class.update(path_or_buffer)
-        else:
-            with open(path_or_buffer, "rb") as file:
-                while True:
-                    data = file.read(buffer_size)
-                    if not data:
-                        break
-                    hashing_class.update(data)
-        return hashing_class.hexdigest()
-
-    def get_file_content(self) -> bytes:
-        """Get the contained bytes of the file.
-
-        Returns
-        -------
-        bytes :
-            The file's content
-
-        """
-        return self.path.read_bytes()
-
-    def write_to(self, directory: Union[str, Path], file_system=None):
-        """Write the file to the specified destination.
-
-        Parameters
-        ----------
-        directory : Union[str, pathlib.Path]
-            Directory where the file should be written.
-        file_system :
-            The target file system.
-
-        """
-        path = Path(f"{directory}/{self.filename}")
-
-        buffer = self.buffer
-        if buffer is None:
-            buffer = self.get_file_content()
-
-        if file_system is None:
-            path.write_bytes(buffer)
-        else:
-            file_system.writebytes(path.as_posix(), buffer)
-
-
 # ASDF Serialization -------------------------------------------------------------------
 
 
diff --git a/weldx/tags/core/media_file.py b/weldx/tags/core/media_file.py
new file mode 100644
index 0000000..5f021aa
--- /dev/null
+++ b/weldx/tags/core/media_file.py
@@ -0,0 +1,42 @@
+"""Contains classes for the asdf serialization of media files."""
+import pathlib
+
+from weldx.asdf.types import WeldxConverter
+from weldx.util.external_file import ExternalFile
+from weldx.util.media_file import MediaFile
+
+
+class MediaFileConverter(WeldxConverter):
+    """Serialization class for `weldx.util.MediaFile`."""
+
+    name = "core/media_file"
+    version = "0.1.0"
+    types = [MediaFile]
+
+    def to_yaml_tree(self, obj: MediaFile, tag: str, ctx) -> dict:
+        """Convert to python dict."""
+        tree = dict(
+            reference_time=obj.reference_time,
+            fps=obj.fps,
+            n_frames=len(obj),
+            resolution=obj.resolution,
+        )
+        if obj.from_file:
+            tree["file"] = obj.file()
+        else:
+            tree["data"] = obj.data
+        return tree
+
+    def from_yaml_tree(self, node: dict, tag: str, ctx):
+        """Construct from tree."""
+        if "data" in node:
+            data = node["data"]
+        elif "file" in node:
+            file: ExternalFile = node["file"]
+            data = pathlib.Path(file.directory) / file.filename
+        else:
+            raise RuntimeError("malformed media file. Lacking keys 'data' or 'file'.")
+        fps = node["fps"]
+        reference_time = node["reference_time"] if "reference_time" in node else None
+        result = MediaFile(data, fps=fps, reference_time=reference_time)
+        return result
diff --git a/weldx/time.py b/weldx/time.py
index ee418d8..9de53a6 100644
--- a/weldx/time.py
+++ b/weldx/time.py
@@ -58,7 +58,7 @@ class Time:
           calculating a time delta between two dates.
 
     This class solves the mentioned problems for many cases. It can be created
-    from many different data types and offers methods to convert back to one of the
+    from many data types and offers methods to convert back to one of the
     supported types. Most of its methods also support the date types that can be used to
     create an instance of this class. Therefore, you do not need to perform any
     conversions yourself.
diff --git a/weldx/util/__init__.py b/weldx/util/__init__.py
index 49f1105..ddeee05 100644
--- a/weldx/util/__init__.py
+++ b/weldx/util/__init__.py
@@ -9,3 +9,5 @@ __all__ = _util_all + _util_xarray
 del _util_all, _util_xarray
 
 _patch_mod_all("weldx.util")  # noqa
+
+from . import external_file, media_file
diff --git a/weldx/util/external_file.py b/weldx/util/external_file.py
new file mode 100644
index 0000000..1e78110
--- /dev/null
+++ b/weldx/util/external_file.py
@@ -0,0 +1,130 @@
+"""External file utilities."""
+import mimetypes
+import socket
+from dataclasses import dataclass
+from hashlib import md5, sha256
+from pathlib import Path
+from typing import Union
+
+import pandas as pd
+
+__all__ = ["ExternalFile"]
+
+
+@dataclass
+class ExternalFile:
+    """Handles the asdf serialization of external files."""
+
+    path: Union[Path] = None
+
+    filename: str = None
+    suffix: str = None
+    mimetype: str = None
+    directory: str = None
+    hostname: str = None
+
+    created: pd.Timestamp = None
+    modified: pd.Timestamp = None
+    size: int = None
+
+    hashing_algorithm: str = "SHA-256"
+    hash: str = None
+    asdf_save_content: bool = False
+    buffer: bytes = None
+
+    hash_mapping = {"MD5": md5, "SHA-256": sha256}
+
+    def __post_init__(self):
+        """Initialize the internal values."""
+        if self.path is not None:
+            if not isinstance(self.path, Path):
+                self.path = Path(self.path)
+            if not self.path.is_file():
+                raise ValueError(f"File not found: {self.path.as_posix()}")
+
+            self.filename = self.path.name
+            self.suffix = "".join(self.path.suffixes)[1:]
+            self.directory = self.path.parent.absolute().as_posix()
+            self.mimetype = mimetypes.guess_type(self.path)[0]
+            if self.hostname is None:
+                self.hostname = socket.gethostname()
+
+            stat = self.path.stat()
+            self.size = stat.st_size
+            self.created = pd.Timestamp(stat.st_ctime_ns)
+            self.modified = pd.Timestamp(stat.st_mtime_ns)
+
+            self.hashing_algorithm = self.hashing_algorithm.upper()
+            if self.hashing_algorithm not in ExternalFile.hash_mapping:
+                raise ValueError(
+                    f"'{self.hashing_algorithm}' is not a supported hashing algorithm."
+                )
+
+    @staticmethod
+    def calculate_hash(
+        path_or_buffer: Union[str, Path, bytes],
+        algorithm: str,
+        buffer_size: int = 65536,
+    ) -> str:
+        """Calculate the hash of a file.
+
+        Parameters
+        ----------
+        path_or_buffer : Union[str, pathlib.Path, bytes]
+            Path of the file or buffer as bytes
+        algorithm : str
+            Name of the desired hashing algorithm
+        buffer_size : int
+            Size of the internally used buffer. The file will be read in
+            corresponding chunks. No effect when hashing from buffer.
+
+        Returns
+        -------
+        str :
+            The calculated hash
+
+        """
+        hashing_class = ExternalFile.hash_mapping[algorithm.upper()]()
+        if isinstance(path_or_buffer, bytes):
+            hashing_class.update(path_or_buffer)
+        else:
+            with open(path_or_buffer, "rb") as file:
+                while True:
+                    data = file.read(buffer_size)
+                    if not data:
+                        break
+                    hashing_class.update(data)
+        return hashing_class.hexdigest()
+
+    def get_file_content(self) -> bytes:
+        """Get the contained bytes of the file.
+
+        Returns
+        -------
+        bytes :
+            The file's content
+
+        """
+        return self.path.read_bytes()
+
+    def write_to(self, directory: Union[str, Path], file_system=None):
+        """Write the file to the specified destination.
+
+        Parameters
+        ----------
+        directory : Union[str, pathlib.Path]
+            Directory where the file should be written.
+        file_system :
+            The target file system.
+
+        """
+        path = Path(f"{directory}/{self.filename}")
+
+        buffer = self.buffer
+        if buffer is None:
+            buffer = self.get_file_content()
+
+        if file_system is None:
+            path.write_bytes(buffer)
+        else:
+            file_system.writebytes(path.as_posix(), buffer)
diff --git a/weldx/util/media_file.py b/weldx/util/media_file.py
new file mode 100644
index 0000000..8451e2c
--- /dev/null
+++ b/weldx/util/media_file.py
@@ -0,0 +1,240 @@
+"""Media file."""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional, Union, get_args, get_type_hints
+
+import numpy as np
+import pandas as pd
+import pint
+import xarray as xr
+
+from weldx import Q_
+from weldx.types import types_path_like
+from weldx.util.external_file import ExternalFile
+
+__all__ = ["types_media_input", "MediaFile", "UnknownFormatError"]
+
+
+types_sequence_like = Union[
+    xr.DataArray,
+    np.ndarray,
+    list,
+    tuple,
+]
+
+types_media_input = Union[
+    types_path_like,
+    types_sequence_like,
+]
+
+# _pts_to_frame, _get_frame_rate, _get_frame_count taken from
+# https://github.com/PyAV-Org/PyAV/blob/v9.1.1/scratchpad/frame_seek_example.py
+_AV_TIME_BASE = 1000000
+
+
+def _pts_to_frame(pts, time_base, frame_rate, start_time):
+    return int(pts * time_base * frame_rate) - int(start_time * time_base * frame_rate)
+
+
+def _get_frame_rate(stream):
+    if stream.average_rate.denominator and stream.average_rate.numerator:
+        return float(stream.average_rate)
+
+    if stream.time_base.denominator and stream.time_base.numerator:
+        return 1.0 / float(stream.time_base)
+    raise ValueError("Unable to determine FPS")
+
+
+def _get_frame_count(f, stream):
+    if stream.frames:
+        return stream.frames
+    if stream.duration:
+        return _pts_to_frame(
+            stream.duration, float(stream.time_base), _get_frame_rate(stream), 0
+        )
+    if f.duration:
+        return _pts_to_frame(
+            f.duration, 1 / float(_AV_TIME_BASE), _get_frame_rate(stream), 0
+        )
+    return float("nan")
+
+
+class UnknownFormatError(Exception):
+    """File format could not be determined."""
+
+
+class MediaFile:
+    """Encapsulates a media file, like video or image stacks making them accessible.
+
+    The underlying images are encapsulated to be loaded lazily (via Dask) and can be
+    accessed by a time coordinate in case of videos.
+
+    Parameters
+    ----------
+    path_or_array :
+        Path pointing towards a video, or tiff stack. If an array is given, it will be
+        treated as a video.
+    reference_time :
+        A reference time, when this media was recorded. Useful, when passing arrays.
+    fps :
+        Frames per second in case of a video. Has to be passed in case ``path_or_array``
+        was given as list of frames.
+    """
+
+    def __init__(
+        self,
+        path_or_array: types_media_input,
+        reference_time: Optional[pd.Timestamp] = None,
+        fps: Optional[float] = None,
+    ):
+        if isinstance(path_or_array, get_args(types_path_like)):
+            self._init_from_path(path_or_array, reference_time)  # type: ignore
+        elif isinstance(path_or_array, get_args(types_sequence_like)):
+            self._init_from_sequence(fps, path_or_array, reference_time)  # type: ignore
+        else:
+            raise ValueError(f"unsupported input: {path_or_array}")
+
+        self._path_or_array = path_or_array
+
+    def _init_from_path(self, path_: types_path_like, reference_time):
+        from dask_image.imread import imread
+        from pims import UnknownFormatError as _UnknownFormatError
+
+        path = Path(path_)  # type: ignore[arg-type]
+        try:
+            self._handle = imread(path_)
+        except _UnknownFormatError as e:
+            # wrap in our exception type to hide impl detail.
+            raise UnknownFormatError(e) from e
+        self._metadata = self._get_video_metadata(str(path))
+        self._wrap_data_array(path_)
+        if reference_time is None:
+            self._reference_time = pd.Timestamp(path.stat().st_mtime_ns)
+        elif isinstance(reference_time, pd.Timestamp):
+            self._reference_time = reference_time
+        else:
+            raise ValueError(
+                f"unsupported type for reference_time {type(reference_time)}"
+            )
+        self._from_file = True
+
+    def _init_from_sequence(
+        self, fps, path_or_array: types_sequence_like, reference_time
+    ):
+        self._handle = path_or_array
+        if fps is None:
+            raise ValueError("fps is needed to determine duration, but was not given.")
+        from PIL.Image import fromarray
+
+        first_frame = self._handle[0]
+        if not hasattr(first_frame, "__array_interface__"):
+            first_frame = first_frame.data  # type: ignore
+        image = fromarray(first_frame)
+        self._metadata = dict(
+            fps=fps,
+            resolution=(image.width, image.height),
+            nframes=len(path_or_array),
+        )
+        _ref_time_types = get_type_hints(MediaFile.__init__)["reference_time"]
+        if not isinstance(reference_time, get_args(_ref_time_types)):
+            raise ValueError(
+                f"unsupported type for reference_time {type(reference_time)}"
+            )
+        self._reference_time = reference_time
+        self._from_file = False
+        self._wrap_data_array(array_name="from_buffer")
+
+    def _wrap_data_array(self, array_name):
+        if isinstance(self._handle, xr.DataArray):
+            self._array = self._handle  # TODO: this is kinda ugly!
+        else:
+            t_s = np.linspace(0, self.duration.m, len(self._handle))
+
+            da = xr.DataArray(self._handle, name=array_name).rename(
+                dict(dim_0="frames", dim_1="height", dim_2="width", dim_3="color")
+            )
+            self._array = da.assign_coords(frames=t_s)
+            self._array.frames.attrs["units"] = "s"
+
+    @property
+    def from_file(self) -> bool:
+        """Initialized from file or not?"""
+        return self._from_file
+
+    @staticmethod
+    def _get_video_metadata(fn: str) -> dict:
+        import av
+
+        with av.open(fn) as v:
+            frame = next(v.decode(), None)
+            if not frame:
+                raise RuntimeError(
+                    "could not determine video metadata, "
+                    "as no single frame could be read."
+                )
+            resolution = frame.width, frame.height
+
+            stream = next((s for s in v.streams if s.type == "video"), None)
+
+            metadata = dict(
+                fps=_get_frame_rate(stream),
+                nframes=_get_frame_count(
+                    frame,
+                    stream,
+                ),
+                resolution=resolution,
+            )
+
+        return metadata
+
+    @property
+    def reference_time(self) -> Optional[pd.Timestamp]:
+        """Time of recording of this media (if known)."""
+        return self._reference_time
+
+    @property
+    def attrs(self):
+        """Video attributes."""
+        from attrdict import AttrDict
+
+        return AttrDict(self._metadata)
+
+    @property
+    def resolution(self) -> tuple[int, int]:
+        """Resolution in pixels (widths, height)."""
+        return self._metadata["resolution"]
+
+    @property
+    def fps(self) -> pint.Quantity:
+        """Frames per second."""
+        return Q_(self._metadata["fps"], "1/s")
+
+    @property
+    def duration(self) -> Optional[pint.Quantity]:
+        """In case of time-dynamic data, return its duration."""
+        return len(self._handle) / self.fps
+
+    def file(self) -> ExternalFile:
+        """File reference to underlying file/directory."""
+        # note: this will rehash every time we serialize
+        # even if the underlying path is unchanged.
+        if not self._from_file:
+            buffer = bytes(np.array(self._path_or_array))
+            return ExternalFile(
+                buffer=buffer, filename="<in-memory-source>", asdf_save_content=True
+            )
+        return ExternalFile(self._path_or_array)  # type: ignore[arg-type]
+
+    @property
+    def data(self) -> xr.DataArray:
+        """Get underlying DataArray."""
+        return self._array
+
+    def __getitem__(self, item):
+        """Delegate slicing etc. to array."""
+        return self._array.__getitem__(item)
+
+    def __len__(self):
+        """Length."""
+        return len(self._array)


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 67a0f7a..b87b5b9 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -40,7 +40,7 @@ jobs:
         with:
           file: 'pyproject.toml'
           channels: 'conda-forge defaults'
-          extras: 'test vis'
+          extras: 'test vis media'
           setup_requires: 'include'
 
       - uses: mamba-org/provision-with-micromamba@main
@@ -53,13 +53,13 @@ jobs:
             wheel
             pip
 
-      - name: activate env
-        run: micromamba activate weldx
-
       - name: pip installs
         run: |
           python -m pip install -e .
 
+      - name: activate env
+        run: micromamba activate weldx
+
       - name: install cookiecutter
         if: matrix.py == '3.8'
         run: |
@@ -136,7 +136,7 @@ jobs:
         with:
           file: 'pyproject.toml'
           channels: 'conda-forge defaults'
-          extras: 'test'
+          extras: 'test vis media'
           setup_requires: 'include'
 
       - uses: mamba-org/provision-with-micromamba@main
diff --git a/weldx/tests/asdf_tests/test_asdf_core.py b/weldx/tests/asdf_tests/test_asdf_core.py
index 3870ecf..d2cdb37 100644
--- a/weldx/tests/asdf_tests/test_asdf_core.py
+++ b/weldx/tests/asdf_tests/test_asdf_core.py
@@ -18,9 +18,9 @@ from weldx.constants import META_ATTR, Q_
 from weldx.core import GenericSeries, TimeSeries
 from weldx.core import MathematicalExpression as ME  # nopep8
 from weldx.geometry import SpatialData
-from weldx.tags.core.file import ExternalFile
 from weldx.tests._helpers import get_test_name
 from weldx.transformations import WXRotation
+from weldx.util.external_file import ExternalFile
 
 # WXRotation ---------------------------------------------------------------------
 _base_rotation = Rotation.from_euler(
@@ -135,8 +135,7 @@ def test_xarray_data_array(copy_arrays, lazy_load, select):
 
 # xarray.Dataset ---------------------------------------------------------------------
 def get_xarray_example_dataset():
-    """
-    Get an xarray.Dataset for test purposes.
+    """Get a xarray.Dataset for test purposes.
 
     Returns
     -------
@@ -770,6 +769,7 @@ class TestExternalFile:
 
         assert ef.filename == ef_file.filename
         assert ef.suffix == ef_file.suffix
+        assert ef.mimetype == ef_file.mimetype == "image/svg+xml"
         assert ef.directory == ef_file.directory
         assert ef.hostname == ef_file.hostname
 
diff --git a/weldx/tests/asdf_tests/test_media_file.py b/weldx/tests/asdf_tests/test_media_file.py
new file mode 100644
index 0000000..34e290f
--- /dev/null
+++ b/weldx/tests/asdf_tests/test_media_file.py
@@ -0,0 +1,95 @@
+"""Tests for MediaFile."""
+import numpy as np
+import pytest
+import xarray as xr
+
+from weldx import Q_, U_, WeldxFile
+from weldx.util.media_file import MediaFile, UnknownFormatError
+
+
+def write_rgb_rotate(output, width, height, n_frames, fps):
+    import PIL.Image as Image
+    from av import VideoFrame
+
+    stream = output.add_stream("mpeg4", fps)
+    stream.width = width
+    stream.height = height
+    stream.pix_fmt = "yuv420p"
+    image = Image.new("RGB", (width, height), (0, 0, 255))
+
+    for _ in range(n_frames):
+        frame = VideoFrame(width, height, "rgb24")
+        frame.planes[0].update(image.tobytes())
+
+        for packet in stream.encode(frame):
+            output.mux(packet)
+
+    for packet in stream.encode(None):
+        output.mux(packet)
+
+    result = [np.array(image)] * n_frames
+    return result
+
+
+@pytest.fixture(scope="module")
+def create_video():
+    """Creates a test video (once per session/module initialization.)"""
+
+    def impl(n_frames, tmp_path, width, height, fps, external=False):
+        import av
+
+        fn_out = str(tmp_path / "data.avi")
+        with av.open(fn_out, "w") as output:
+            frames = write_rgb_rotate(output, width, height, n_frames, fps)
+
+        if external:
+            return fn_out
+
+        return frames
+
+    return impl
+
+
+@pytest.mark.parametrize("external", [True, False])
+def test_media_file_external(external, tmp_path, create_video):
+    """Test MediaFile encapsulation with external avi file and in-memory frames."""
+    # create a video with given specs
+    n_frames = 10
+    fps = 3
+    height = 200
+    width = 320
+    data = create_video(
+        n_frames, tmp_path, height=height, width=width, external=external, fps=fps
+    )
+    # encapsulate in MediaFile
+    args = dict(fps=fps, reference_time=None) if not external else {}
+    mf = MediaFile(data, **args)
+
+    WeldxFile(tmp_path / "mf.wx", tree=dict(mf=mf), mode="rw")
+    wf_readonly = WeldxFile(tmp_path / "mf.wx", mode="r")
+    restored = wf_readonly["mf"]
+    assert restored.fps.u == U_("1/s")
+    assert mf.fps.m == restored.fps.m == fps
+
+    assert mf.duration == restored.duration == Q_(n_frames / fps, "s")
+    assert restored.duration.u == U_("s")
+
+    assert mf.resolution == restored.resolution == (width, height)
+
+    if external:
+        assert str(mf.file().path) == data
+
+    # compare content
+    first_frame_input = data[0]
+    first_frame_restored = restored[0].compute()
+    if not external:  # data are frames!
+        np.testing.assert_equal(first_frame_input, first_frame_restored)
+    # compare first frames...
+    xr.testing.assert_equal(mf[0].compute(), first_frame_restored)
+
+
+def test_unknown_file_format(tmp_path):
+    """Ensure video decoder cannot be determined from the file extension raises."""
+    f = tmp_path / "some_file.bin"
+    with pytest.raises(UnknownFormatError):
+        MediaFile(f)