Skip to content

Commit

Permalink
Display movie and track information as tables (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
gBillal authored Dec 29, 2024
1 parent 69c49a2 commit 7a7d6ee
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 8 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The package can be installed by adding `ex_mp4` to your list of dependencies in
```elixir
def deps do
[
{:ex_mp4, "~> 0.8.0"}
{:ex_mp4, "~> 0.8.1"}
]
end
```
Expand Down
2 changes: 1 addition & 1 deletion examples/copy_track.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule TrackCopier do
|> Writer.add_track(video_track)

Reader.stream(reader, tracks: [video_track.id])
|> Reader.samples(reader)
|> Stream.map(&Reader.read_sample(reader, &1))
|> Stream.map(&%{&1 | track_id: 1})
|> Enum.into(writer)
|> Writer.write_trailer()
Expand Down
44 changes: 42 additions & 2 deletions lib/ex_mp4/helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,29 @@ defmodule ExMP4.Helper do
Helper functions.
"""

@units [:nanosecond, :microsecond, :millisecond, :second]

@type timescale :: :nanosecond | :microsecond | :millisecond | :second | integer | Ratio.t()

@units [:nanosecond, :microsecond, :millisecond, :second]
@seconds_in_hour 3_600
@seconds_in_minute 60

@doc """
Convert duration between different timescales.
iex> ExMP4.Helper.timescalify(1900, 90000, :millisecond)
21
iex> ExMP4.Helper.timescalify(21, :millisecond, 90_000)
1890
iex> ExMP4.Helper.timescalify(10, Ratio.new(30_000, 1001), Ratio.new(40, 2))
7
iex> ExMP4.Helper.timescalify(1600, :millisecond, :second)
2
iex> ExMP4.Helper.timescalify(15, :nanosecond, :nanosecond)
15
"""
@spec timescalify(Ratio.t() | integer, timescale(), timescale()) :: integer
def timescalify(time, timescale, timescale), do: time
Expand All @@ -23,10 +40,33 @@ defmodule ExMP4.Helper do
Ratio.trunc(time * target_timescale / source_timescale + 0.5)
end

@doc """
Format a `millisecond` duration as `H:MM:ss.mmm`
iex> ExMP4.Helper.format_duration(100)
"0:00:00.100"
iex> ExMP4.Helper.format_duration(165_469_850)
"45:57:49.850"
"""
@spec format_duration(non_neg_integer()) :: String.t()
def format_duration(duration_ms) do
milliseconds = rem(duration_ms, 1000)
duration = div(duration_ms, 1000)

{hours, duration} = div_rem(duration, @seconds_in_hour)
{minutes, seconds} = div_rem(duration, @seconds_in_minute)

"#{hours}:#{pad_value(minutes)}:#{pad_value(seconds)}.#{milliseconds}"
end

defp convert_unit(:nanosecond), do: 10 ** 9
defp convert_unit(:microsecond), do: 10 ** 6
defp convert_unit(:millisecond), do: 10 ** 3
defp convert_unit(:second), do: 1
defp convert_unit(integer) when is_integer(integer), do: integer
defp convert_unit(unit), do: raise("Expected one of #{inspect(@units)}, got: #{inspect(unit)}")

defp div_rem(a, b), do: {div(a, b), rem(a, b)}
defp pad_value(a), do: a |> to_string() |> String.pad_leading(2, "0")
end
14 changes: 12 additions & 2 deletions lib/ex_mp4/reader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule ExMP4.Reader do
* `duration` - The duration of the mp4 mapped in `:timescale` unit.
* `timescale` - The timescale of the mp4.
* `fragmented?` - The MP4 file is fragmented.
* `progressive?` - The MP4 file is progressive.
* `creation_time` - Creation date time of the presentation.
* `modification_time` - Modification date time of the presentation.
* `major_brand`
Expand Down Expand Up @@ -57,7 +58,7 @@ defmodule ExMP4.Reader do
- `tracks` - stream only the specified tracks.
"""
@type stream_opts :: [tracks: [non_neg_integer()]]
@type stream_opts :: [tracks: [non_neg_integer()] | non_neg_integer()]

@typedoc """
Struct describing an MP4 reader.
Expand All @@ -69,6 +70,7 @@ defmodule ExMP4.Reader do
minor_version: integer(),
compatible_brands: [binary()],
fragmented?: boolean(),
progressive?: boolean(),
creation_time: DateTime.t(),
modification_time: DateTime.t(),

Expand All @@ -86,6 +88,7 @@ defmodule ExMP4.Reader do
:minor_version,
:compatible_brands,
:fragmented?,
:progressive?,
:creation_time,
:modification_time,
:reader_mod,
Expand Down Expand Up @@ -184,6 +187,7 @@ defmodule ExMP4.Reader do

acc =
Keyword.get(opts, :tracks, Map.keys(reader.tracks))
|> List.wrap()
|> Enum.map(fn track_id ->
track = Map.fetch!(reader.tracks, track_id)
{track, nil, &Enumerable.reduce(track, &1, step)}
Expand Down Expand Up @@ -263,6 +267,7 @@ defmodule ExMP4.Reader do
creation_time: moov.mvhd.creation_time,
modification_time: moov.mvhd.modification_time,
fragmented?: not is_nil(moov.mvex),
progressive?: is_nil(reader.progressive?),
tracks: tracks
}
end
Expand Down Expand Up @@ -294,7 +299,12 @@ defmodule ExMP4.Reader do
|> then(&%{reader | tracks: &1, duration: max_duration(reader, Map.values(&1))})
end

defp do_parse_metadata(reader, _box_nale, content_size, rest) do
defp do_parse_metadata(reader, box_name, content_size, rest) do
reader =
if box_name == "mdat",
do: %{reader | progressive?: reader.progressive? || false},
else: reader

skip(reader, content_size, rest)
end

Expand Down
167 changes: 167 additions & 0 deletions lib/ex_mp4/reader/display.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
if Application.ensure_loaded(TableRex) do
defmodule ExMP4.Reader.Display do
@moduledoc """
Show information about MP4 files and tracks using [TableRex](https://hex.pm/packages/table_rex).
To use this module, you need to add `table_rex` to your dependencies.
To show basic information about the whole movie
ExMP4.Reader.Display.movie_info(reader) |> IO.puts()
+--------------------------------------------------------+
| Movie Info |
+===========================+============================+
| Duration / Timescale | 2759320/1000 (0:45:59.320) |
| Brands (major/compatible) | mp42,isom,mp42 |
| Progressive | true |
| Fragmented | false |
| Creation Date | 1904-01-01 00:00:00Z |
| Modification Date | 1904-01-01 00:00:00Z |
+---------------------------+----------------------------+
Or to show a description of the tracks
ExMP4.Reader.Display.tracks_info(reader) |> IO.puts()
+-------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Video track(s) info |
+====+=======================+========================+===========+===================+================+=======+=======+========+=============+===============+
| ID | Presentation Duration | Duration | Timescale | Number of Samples | Bitrate (kbps) | Codec | Width | Height | Sample Rate | Channel Count |
+----+-----------------------+------------------------+-----------+-------------------+----------------+-------+-------+--------+-------------+---------------+
| 1 | 2759320 - 0:45:59.320 | 35319296 - 0:45:59.320 | 12800 | 68983 | 1684 | H264 | 1920 | 816 | | |
+----+-----------------------+------------------------+-----------+-------------------+----------------+-------+-------+--------+-------------+---------------+
+--------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Audio track(s) info |
+====+=======================+=========================+===========+===================+================+=======+=======+========+=============+===============+
| ID | Presentation Duration | Duration | Timescale | Number of Samples | Bitrate (kbps) | Codec | Width | Height | Sample Rate | Channel Count |
+----+-----------------------+-------------------------+-----------+-------------------+----------------+-------+-------+--------+-------------+---------------+
| 2 | 2759320 - 0:45:59.320 | 121686016 - 0:45:59.320 | 44100 | 118834 | 128 | AAC | | | 44100 | 2 |
+----+-----------------------+-------------------------+-----------+-------------------+----------------+-------+-------+--------+-------------+---------------+
"""

import ExMP4.Helper, only: [timescalify: 3, format_duration: 1]

alias ExMP4.Reader

@type samples_options() :: [limit: non_neg_integer(), offset: non_neg_integer()]

@doc """
Display information about the whole movie.
"""
@spec movie_info(Reader.t()) :: String.t()
def movie_info(%Reader{} = reader) do
title = "Movie Info"
brands = Enum.join([reader.major_brand | reader.compatible_brands], ",")
duration_ms = timescalify(reader.duration, reader.timescale, :millisecond)

rows = [
[
"Duration / Timescale",
"#{reader.duration}/#{reader.timescale} (#{format_duration(duration_ms)})"
],
["Brands (major/compatible)", brands],
["Progressive", reader.progressive?],
["Fragmented", reader.fragmented?],
["Creation Date", reader.creation_time],
["Modification Date", reader.modification_time]
]

rows
|> TableRex.Table.new([], title)
|> TableRex.Table.render!(title_separator_symbol: "=")
end

@doc """
Display tracks information.
"""
@spec tracks_info(Reader.t()) :: String.t()
def tracks_info(%Reader{} = reader) do
Reader.tracks(reader)
|> Enum.reduce(%{}, fn track, tracks ->
Map.update(tracks, track.type, [track], &(&1 ++ [track]))
end)
|> Enum.map(&track_info(reader, elem(&1, 0), elem(&1, 1)))
|> Enum.join()
end

@doc """
Display samples from a track.
"""
@spec samples(Reader.t(), ExMP4.Track.id(), Keyword.t()) :: String.t()
def samples(%Reader{} = reader, track_id, opts \\ []) do
track = Reader.track(reader, track_id)
title = "Sample Table"
headers = ["number", "dts", "cts", "offset", "size", "sync?"]

offset = Keyword.get(opts, :offset, 0)

rows =
reader
|> Reader.stream(tracks: track_id)
|> Stream.drop(offset)
|> Stream.take(opts[:limit] || 10)
|> Stream.with_index(offset)
|> Enum.map(fn {sample, num} ->
dts_ms = timescalify(sample.dts, track.timescale, :millisecond)
pts_ms = timescalify(sample.pts, track.timescale, :millisecond)

[
num,
"#{sample.dts} - #{format_duration(dts_ms)}",
"#{sample.pts} - #{format_duration(pts_ms)}",
sample.offset,
sample.size,
sample.sync?
]
end)

rows
|> TableRex.Table.new(headers, title)
|> TableRex.Table.render!(title_separator_symbol: "=")
end

defp track_info(reader, type, tracks) do
title = "#{String.capitalize(to_string(type))} track(s) info"
movie_duration_ms = Reader.duration(reader, :millisecond)

headers = [
"ID",
"Presentation Duration",
"Duration",
"Timescale",
"Number of Samples",
"Bitrate (kbps)",
"Codec",
"Width",
"Height",
"Sample Rate",
"Channel Count"
]

rows =
Enum.map(tracks, fn track ->
track_duration_ms = ExMP4.Track.duration(track, :millisecond)

[
track.id,
"#{reader.duration} - #{format_duration(movie_duration_ms)}",
"#{track.duration} - #{format_duration(track_duration_ms)}",
track.timescale,
track.sample_count,
div(ExMP4.Track.bitrate(track), 1000),
track.media |> to_string() |> String.upcase(),
track.width,
track.height,
track.sample_rate,
track.channels
]
end)

rows
|> TableRex.Table.new(headers, title)
|> TableRex.Table.render!(title_separator_symbol: "=")
end
end
end
9 changes: 7 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule ExMP4.MixProject do
use Mix.Project

@version "0.8.0"
@version "0.8.1"
@github_url "https://github.com/gBillal/ex_mp4"

def project do
Expand Down Expand Up @@ -39,6 +39,7 @@ defmodule ExMP4.MixProject do
[
{:ratio, "~> 4.0"},
{:bunch, "~> 1.6"},
{:table_rex, "~> 4.0", optional: true},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
{:credo, ">= 0.0.0", only: :dev, runtime: false}
Expand Down Expand Up @@ -68,8 +69,8 @@ defmodule ExMP4.MixProject do
formatters: ["html"],
source_ref: "v#{@version}",
nest_modules_by_prefix: [
ExMP4.BitStreamFilter,
ExMP4.Box,
ExMP4.Container,
ExMP4.Track
],
groups_for_modules: [
Expand All @@ -83,11 +84,15 @@ defmodule ExMP4.MixProject do
"ExMP4.SampleMetadata",
"ExMP4.Helper"
],
Display: [
"ExMP4.Reader.Display"
],
Behaviour: [
~r/^ExMP4\.DataReader($|\.)/,
~r/^ExMP4\.DataWriter($|\.)/,
~r/^ExMP4\.FragDataWriter($|\.)/
],
BitStream: ~r/^ExMP4\.BitStreamFilter($|\.)/,
Box: ~r/^ExMP4\.Box($|\.)/
]
]
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"numbers": {:hex, :numbers, "5.2.4", "f123d5bb7f6acc366f8f445e10a32bd403c8469bdbce8ce049e1f0972b607080", [:mix], [{:coerce, "~> 1.0", [hex: :coerce, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "eeccf5c61d5f4922198395bf87a465b6f980b8b862dd22d28198c5e6fab38582"},
"ratio": {:hex, :ratio, "4.0.1", "3044166f2fc6890aa53d3aef0c336f84b2bebb889dc57d5f95cc540daa1912f8", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:numbers, "~> 5.2.0", [hex: :numbers, repo: "hexpm", optional: false]}], "hexpm", "c60cbb3ccdff9ffa56e7d6d1654b5c70d9f90f4d753ab3a43a6bf40855b881ce"},
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
}
7 changes: 7 additions & 0 deletions test/ex_mp4/helper_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule ExMP4.HelperTest do
@moduledoc false

use ExUnit.Case, async: true

doctest ExMP4.Helper
end

0 comments on commit 7a7d6ee

Please sign in to comment.