Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Crop Video

examples/crop.livemd

Crop Video

Mix.install([:ex_mp4, {:kino, "~> 0.11.0"}])

Crop Video

In this example, we’ll crop an mp4 file by getting samples between two timestamps. The goal of this example is to show how to seek in a file.

First let’s add a module that’ll do the seeking and writing. The process is as follows:

Seek to a keyframe

Since we cannot just start writing video samples from an arbitrary position, we need first to lookup the nearest key frame before the requested time to ensure that the video track is decodable when playing.

Get track ranges

From the last step, we’ll get the start time and end time in the video track timescale, we’ll convert this times to each track timescale. So that yields a map with the track id as a key and a tuple of start time and end time as a value.

Stream the samples metadata

Next we stream the samples’ metadata and filter out samples that are not in the range calculated from the last step

Write the samples

Last step would be to create a writer, add the tracks and store the filtered samples.

defmodule VideoCropper do
  require Logger

  alias ExMP4.{Helper, Reader, Writer}

  def crop(video_path, dest_path, start_time, duration) do
    reader = Reader.new!(video_path)

    {start_time, end_time, timescale} = get_real_range(reader, start_time, duration)
    track_ranges = get_track_ranges(reader, start_time, end_time, timescale)

    tracks = Reader.tracks(reader) |> Enum.sort_by(& &1.id)

    writer =
      Writer.new!(dest_path)
      |> Writer.write_header()
      |> Writer.add_tracks(tracks)

    Reader.stream(reader)
    |> Stream.filter(fn metadata ->
      {s, e} = track_ranges[metadata.track_id]
      metadata.dts >= s and metadata.dts <= e
    end)
    |> Reader.samples(reader)
    |> Enum.into(writer)
    |> Writer.write_trailer()
  end

  defp get_real_range(reader, start_time, duration) do
    video_track =
      reader
      |> Reader.tracks()
      |> Enum.find(&amp;(&amp;1.type == :video))

    end_time = Helper.timescalify(start_time + duration, :second, video_track.timescale)
    start_time = Helper.timescalify(start_time, :second, video_track.timescale)

    # fetch the starting point
    start_time =
      Reader.stream(reader, tracks: [video_track.id])
      |> Enum.reduce_while(0, fn metadata, offset ->
        cond do
          metadata.dts >= start_time -> {:halt, offset}
          metadata.sync? -> {:cont, metadata.dts}
          true -> {:cont, offset}
        end
      end)

    {start_time, end_time, video_track.timescale}
  end

  defp get_track_ranges(reader, start_time, end_time, timescale) do
    Reader.tracks(reader)
    |> Enum.map(fn track ->
      range_start = Helper.timescalify(start_time, timescale, track.timescale)
      range_end = Helper.timescalify(end_time, timescale, track.timescale)

      {track.id, {range_start, range_end}}
    end)
    |> Map.new()
  end
end

Next we create a form that accepts the source path and the destination of the new MP4 file.

form =
  Kino.Control.form(
    [
      source_path: Kino.Input.text("Source Path"),
      dest_path: Kino.Input.text("Destination Path"),
      start_time: Kino.Input.number("Start time", default: 5),
      duration: Kino.Input.number("Duration", default: 10)
    ],
    submit: "Submit"
  )

Kino.listen(form, fn %{data: data} ->
  :ok = VideoCropper.crop(data.source_path, data.dest_path, data.start_time, data.duration)
  IO.inspect("Done cropping")
end)

form