Skip to main content

lib/iso_media.ex

defmodule ISOMedia do
  @moduledoc """
  Lossless ISOBMFF (MP4/MOV/M4A/HEIF) box surgery in pure Elixir.

  Parse any ISO Base Media file into a tree of `ISOMedia.Box` structs (every box,
  including unknown/vendor boxes, preserved byte-for-byte), edit it, and re-serialize.
  The invariant throughout is `serialize(parse(file)) == file`.

  This module is the top-level entry point:

    * Read/write — `read/2` (with `lazy: true` for files larger than RAM), `write/2`,
      `parse/2`, `serialize/1`.
    * Arrange — `faststart/1`, `fix_chunk_offsets/1`.
    * Sample-level — `track_ids/1`, `samples/2` (progressive or fragmented),
      `extract_track/2`, `trim/3`, `concat/1`.
    * Fragmented MP4 — `fragment/2` (progressive → fMP4) and `defragment/1` (fMP4 →
      progressive), inverses of each other.

  `trim`, `extract_track`, `concat`, `fragment`, and `defragment` outputs can be chained
  in memory (no disk round-trip); `faststart/1`/`fix_chunk_offsets/1` require an original
  parsed `mdat` and raise on a synthesized one.

      iex> {:ok, boxes} = ISOMedia.parse(<<8::32, "free">>)
      iex> ISOMedia.serialize(boxes)
      <<8::32, "free">>
  """

  alias ISOMedia.{Parser, Serializer}

  @type tree :: [ISOMedia.Box.t()]

  @doc "Parse a binary into `{:ok, [%ISOMedia.Box{}]}`. See `ISOMedia.Parser.parse/2`."
  @spec parse(binary(), keyword()) :: {:ok, tree()} | {:error, String.t()}
  def parse(binary, opts \\ []), do: Parser.parse(binary, opts)

  @doc "Serialize a box or list of boxes back to a binary."
  @spec serialize(tree()) :: binary()
  def serialize(boxes), do: Serializer.serialize(boxes)

  @doc "List the `track_id`s present in the movie."
  @spec track_ids(tree()) :: [pos_integer()]
  def track_ids(boxes), do: ISOMedia.Extract.track_ids(boxes)

  @doc "Decode a track's sample tables into `[%ISOMedia.Sample{}]` (progressive or fragmented)."
  @spec samples(tree(), pos_integer()) :: [ISOMedia.Sample.t()]
  def samples(boxes, track_id) do
    if ISOMedia.FragmentIndex.fragmented?(boxes) do
      ISOMedia.FragmentIndex.samples(boxes, track_id)
    else
      case ISOMedia.Extract.find_trak(boxes, track_id) do
        nil -> raise ArgumentError, "no track with track_id #{track_id}"
        trak -> ISOMedia.SampleTable.build(trak)
      end
    end
  end

  @doc "Extract a single track into a new box tree (then `write/2` or `serialize/1`)."
  @spec extract_track(tree(), pos_integer()) :: tree()
  def extract_track(boxes, track_id), do: ISOMedia.Extract.extract_track(boxes, track_id)

  @doc "Losslessly trim every track to the time range `[start_sec, end_sec)`."
  @spec trim(tree(), number(), number()) :: tree()
  def trim(boxes, start_sec, end_sec), do: ISOMedia.Trim.trim(boxes, start_sec, end_sec)

  @doc "Losslessly concatenate compatible clips end-to-end. See `ISOMedia.Concat.concat/1`."
  @spec concat([tree()]) :: tree()
  def concat(inputs) when is_list(inputs), do: ISOMedia.Concat.concat(inputs)

  @doc "Defragment a fragmented MP4 tree into a progressive one. See `ISOMedia.Defragment.defragment/1`."
  @spec defragment(tree()) :: tree()
  def defragment(boxes), do: ISOMedia.Defragment.defragment(boxes)

  @doc "Repack a progressive tree into a multiplexed fragmented MP4. See `ISOMedia.Fragment.fragment/2`."
  @spec fragment(tree(), keyword()) :: tree()
  def fragment(boxes, opts \\ []), do: ISOMedia.Fragment.fragment(boxes, opts)

  @doc "Split a fragmented tree into a CMAF init segment + media segments. See `ISOMedia.Segment.split/1`."
  @spec split_segments(tree()) :: %{init: tree(), segments: [tree()]}
  def split_segments(boxes), do: ISOMedia.Segment.split(boxes)

  @doc "Write a fragmented tree's init + media segment files into `dir`. See `ISOMedia.Segment.write_segments/3`."
  @spec write_segments(Path.t(), tree(), keyword()) :: {:ok, [Path.t()]} | {:error, term()}
  def write_segments(dir, boxes, opts \\ []),
    do: ISOMedia.Segment.write_segments(dir, boxes, opts)

  @doc "Generate the HLS media playlist (`.m3u8`) for a fragmented tree. See `ISOMedia.HLS.media_playlist/2`."
  @spec hls_media_playlist(tree(), keyword()) :: String.t()
  def hls_media_playlist(boxes, opts \\ []), do: ISOMedia.HLS.media_playlist(boxes, opts)

  @doc "Generate the HLS multivariant playlist (`.m3u8`) for a fragmented tree. See `ISOMedia.HLS.master_playlist/2`."
  @spec hls_master_playlist(tree(), keyword()) :: String.t()
  def hls_master_playlist(boxes, opts \\ []), do: ISOMedia.HLS.master_playlist(boxes, opts)

  @doc "Write a full HLS bundle (playlists + segments) into `dir`. See `ISOMedia.HLS.write_hls/3`."
  @spec write_hls(Path.t(), tree(), keyword()) :: {:ok, [Path.t()]}
  def write_hls(dir, boxes, opts \\ []), do: ISOMedia.HLS.write_hls(dir, boxes, opts)

  @doc "Generate the DASH MPD (`.mpd`) for a fragmented tree. See `ISOMedia.DASH.manifest/2`."
  @spec dash_manifest(tree(), keyword()) :: String.t()
  def dash_manifest(boxes, opts \\ []), do: ISOMedia.DASH.manifest(boxes, opts)

  @doc "Write a full DASH bundle (MPD + segments) into `dir`. See `ISOMedia.DASH.write_dash/3`."
  @spec write_dash(Path.t(), tree(), keyword()) :: {:ok, [Path.t()]}
  def write_dash(dir, boxes, opts \\ []), do: ISOMedia.DASH.write_dash(dir, boxes, opts)

  @doc "Decode a track's codec + media metadata into `%ISOMedia.TrackInfo{}`. See `ISOMedia.Codec.info/1`."
  @spec track_info(tree(), pos_integer()) :: ISOMedia.TrackInfo.t()
  def track_info(boxes, track_id) do
    case ISOMedia.Extract.find_trak(boxes, track_id) do
      nil -> raise ArgumentError, "track_info: no track with track_id #{track_id}"
      trak -> ISOMedia.Codec.info(trak)
    end
  end

  @doc "Recompute stco/co64 chunk offsets for the current box arrangement. See `ISOMedia.Offsets.fix_chunk_offsets/1`."
  @spec fix_chunk_offsets(tree()) :: tree()
  def fix_chunk_offsets(boxes), do: ISOMedia.Offsets.fix_chunk_offsets(boxes)

  @doc "Move `moov` before `mdat` (faststart) and fix chunk offsets. See `ISOMedia.Offsets.faststart/1`."
  @spec faststart(tree()) :: tree()
  def faststart(boxes), do: ISOMedia.Offsets.faststart(boxes)

  @doc """
  Read a file and parse it. Pass `lazy: true` to keep large leaf payloads
  (≥ `:lazy_threshold`, default 1 MB) as `ISOMedia.FileSlice` references instead of
  loading them, so files larger than memory can be processed.
  """
  @spec read(Path.t(), keyword()) :: {:ok, tree()} | {:error, term()}
  def read(path, opts \\ []) do
    if Keyword.get(opts, :lazy, false) do
      ISOMedia.LazyParser.parse_file(path, opts)
    else
      with {:ok, binary} <- File.read(path), do: parse(binary, opts)
    end
  end

  @doc """
  Serialize boxes and write them to `path`, streaming any `FileSlice` payloads
  disk→disk (memory-safe for large files). Raises if `path` is one of the tree's
  `FileSlice` sources (you cannot stream-overwrite the file you are reading).
  """
  @spec write(Path.t(), tree()) :: :ok | {:error, File.posix()}
  def write(path, boxes) do
    check_overwrite!(path, boxes)

    case File.open(path, [:write, :binary, :raw], fn io ->
           ISOMedia.Serializer.stream(boxes, io)
         end) do
      {:ok, :ok} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp check_overwrite!(path, boxes) do
    out_expanded = Path.expand(path)
    out_id = file_id(path)

    boxes
    |> collect_slice_paths()
    |> Enum.uniq()
    |> Enum.each(fn src ->
      cond do
        Path.expand(src) == out_expanded ->
          raise ArgumentError,
                "write/2: output #{path} is also a FileSlice source; write to a different file"

        out_id != nil and out_id == file_id(src) ->
          raise ArgumentError,
                "write/2: output #{path} resolves to the same file as a FileSlice source (#{src}); write to a different file"

        true ->
          :ok
      end
    end)
  end

  defp file_id(path) do
    case File.stat(path) do
      {:ok, %File.Stat{major_device: maj, minor_device: min, inode: ino}} -> {maj, min, ino}
      _ -> nil
    end
  end

  defp collect_slice_paths(boxes) when is_list(boxes),
    do: Enum.flat_map(boxes, &collect_slice_paths/1)

  defp collect_slice_paths(%ISOMedia.Box{data: %ISOMedia.FileSlice{path: p}}), do: [p]

  defp collect_slice_paths(%ISOMedia.Box{data: parts}) when is_list(parts),
    do: slice_paths_in(parts)

  defp collect_slice_paths(%ISOMedia.Box{data: nil, children: children}),
    do: collect_slice_paths(children)

  defp collect_slice_paths(%ISOMedia.Box{}), do: []

  defp slice_paths_in(parts) do
    Enum.flat_map(parts, fn
      %ISOMedia.FileSlice{path: p} -> [p]
      bin when is_binary(bin) -> []
      nested when is_list(nested) -> slice_paths_in(nested)
    end)
  end
end