From 6151138525367f5d3b28f0623ce55a27506f8d0a Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 14 Jun 2024 21:26:12 -0400 Subject: [PATCH] wip --- lib/philomena_media/libavcodec/aac.ex | 36 ++++ .../libavcodec/encode_stream.ex | 36 ++++ lib/philomena_media/libavcodec/gif.ex | 36 ++++ lib/philomena_media/libavcodec/libopus.ex | 36 ++++ lib/philomena_media/libavcodec/libvpx.ex | 50 +++++ lib/philomena_media/libavcodec/libx264.ex | 50 +++++ lib/philomena_media/libavfilter/endpoint.ex | 58 ++++++ .../libavfilter/filter_graph.ex | 194 ++++++++++++++++++ .../libavfilter/filter_node.ex | 62 ++++++ lib/philomena_media/libavfilter/palettegen.ex | 58 ++++++ lib/philomena_media/libavfilter/paletteuse.ex | 67 ++++++ lib/philomena_media/libavfilter/scale.ex | 61 ++++++ 12 files changed, 744 insertions(+) create mode 100644 lib/philomena_media/libavcodec/aac.ex create mode 100644 lib/philomena_media/libavcodec/encode_stream.ex create mode 100644 lib/philomena_media/libavcodec/gif.ex create mode 100644 lib/philomena_media/libavcodec/libopus.ex create mode 100644 lib/philomena_media/libavcodec/libvpx.ex create mode 100644 lib/philomena_media/libavcodec/libx264.ex create mode 100644 lib/philomena_media/libavfilter/endpoint.ex create mode 100644 lib/philomena_media/libavfilter/filter_graph.ex create mode 100644 lib/philomena_media/libavfilter/filter_node.ex create mode 100644 lib/philomena_media/libavfilter/palettegen.ex create mode 100644 lib/philomena_media/libavfilter/paletteuse.ex create mode 100644 lib/philomena_media/libavfilter/scale.ex diff --git a/lib/philomena_media/libavcodec/aac.ex b/lib/philomena_media/libavcodec/aac.ex new file mode 100644 index 00000000..a74b06b8 --- /dev/null +++ b/lib/philomena_media/libavcodec/aac.ex @@ -0,0 +1,36 @@ +defmodule PhilomenaMedia.Libavcodec.Aac do + @moduledoc """ + Represents the `aac` encoder, which takes an audio input and generates encoded output. + + ## Example + + Aac.new() + + No options are exposed. However, see https://ffmpeg.org/ffmpeg-codecs.html#aac for + additional information. + """ + + @type opts :: [] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + force_format: nil, + type: :audio + } + + defstruct name: "aac", + opts: [], + force_format: nil, + type: :audio + + @doc """ + Construct a new AAC encoder. + + See module documentation for usage. + """ + @spec new() :: t() + def new do + %__MODULE__{} + end +end diff --git a/lib/philomena_media/libavcodec/encode_stream.ex b/lib/philomena_media/libavcodec/encode_stream.ex new file mode 100644 index 00000000..7880f5fb --- /dev/null +++ b/lib/philomena_media/libavcodec/encode_stream.ex @@ -0,0 +1,36 @@ +defmodule PhilomenaMedia.Libavcodec.EncodeStream do + @moduledoc """ + Represents a stream which encodes data. + """ + + @type threads :: non_neg_integer() + @type slices :: non_neg_integer() + @type max_muxing_queue_size :: non_neg_integer() + + @type opts :: [ + threads: threads(), + slices: slices(), + max_muxing_queue_size: max_muxing_queue_size() + ] + + @type t :: %__MODULE__{ + encoder: nil | struct(), + opts: opts() + } + + defstruct encoder: nil, + opts: [] + + @doc """ + Constructs a new encode stream. + + See the individual encoders for additional options. + """ + @spec new(opts(), nil | struct()) :: t() + def new(opts, encoder \\ nil) do + %__MODULE__{ + encoder: encoder, + opts: Keyword.take(opts, [:threads, :slices, :max_muxing_queue_size]) + } + end +end diff --git a/lib/philomena_media/libavcodec/gif.ex b/lib/philomena_media/libavcodec/gif.ex new file mode 100644 index 00000000..cf1fc0b9 --- /dev/null +++ b/lib/philomena_media/libavcodec/gif.ex @@ -0,0 +1,36 @@ +defmodule PhilomenaMedia.Libavcodec.Gif do + @moduledoc """ + Represents the `gif` encoder, which takes a video input and generates encoded output. + + ## Example + + Gif.new() + + No options are exposed. However, see https://ffmpeg.org/ffmpeg-codecs.html#GIF for + additional information. + """ + + @type opts :: [] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + force_format: nil, + type: :video + } + + defstruct name: "gif", + opts: [], + force_format: nil, + type: :video + + @doc """ + Construct a new GIF encoder. + + See module documentation for usage. + """ + @spec new() :: t() + def new do + %__MODULE__{} + end +end diff --git a/lib/philomena_media/libavcodec/libopus.ex b/lib/philomena_media/libavcodec/libopus.ex new file mode 100644 index 00000000..8ea3c351 --- /dev/null +++ b/lib/philomena_media/libavcodec/libopus.ex @@ -0,0 +1,36 @@ +defmodule PhilomenaMedia.Libavcodec.Libopus do + @moduledoc """ + Represents the `libopus` encoder, which takes an audio input and generates encoded output. + + ## Example + + Libopus.new() + + No options are exposed. However, see https://ffmpeg.org/ffmpeg-codecs.html#libopus-1 for + additional information. + """ + + @type opts :: [] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + force_format: nil, + type: :audio + } + + defstruct name: "libopus", + opts: [], + force_format: nil, + type: :audio + + @doc """ + Construct a new libopus encoder. + + See module documentation for usage. + """ + @spec new() :: t() + def new do + %__MODULE__{} + end +end diff --git a/lib/philomena_media/libavcodec/libvpx.ex b/lib/philomena_media/libavcodec/libvpx.ex new file mode 100644 index 00000000..03206b69 --- /dev/null +++ b/lib/philomena_media/libavcodec/libvpx.ex @@ -0,0 +1,50 @@ +defmodule PhilomenaMedia.Libavcodec.Libvpx do + @moduledoc """ + Represents the `libvpx` (VP8) encoder, which takes a video input and generates encoded output. + + ## Example with all options + + Libvpx.new( + deadline: :good, + "cpu-used": 5, + crf: 31 + ) + + See https://ffmpeg.org/ffmpeg-codecs.html#libvpx for more information about the options. + """ + + @type deadline :: :best | :good | :realtime + @type cpu_used :: -16..16 + @type qrange :: 0..63 + @type crf :: qrange() + + @type opts :: [ + deadline: deadline(), + "cpu-used": cpu_used(), + crf: qrange() + ] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + force_format: :yuv420p, + type: :video + } + + defstruct name: "libvpx", + opts: [], + force_format: :yuv420p, + type: :video + + @doc """ + Construct a new libvpx (VP8) encoder. + + See module documentation for usage. + """ + @spec new(opts()) :: t() + def new(opts) do + %__MODULE__{ + opts: Keyword.take(opts, [:deadline, :"cpu-used", :crf]) + } + end +end diff --git a/lib/philomena_media/libavcodec/libx264.ex b/lib/philomena_media/libavcodec/libx264.ex new file mode 100644 index 00000000..e15f580c --- /dev/null +++ b/lib/philomena_media/libavcodec/libx264.ex @@ -0,0 +1,50 @@ +defmodule PhilomenaMedia.Libavcodec.Libx264 do + @moduledoc """ + Represents the `libx264` (H.264) encoder, which takes a video input and generates encoded output. + + ## Example with all options + + Libx264.new( + profile: :main, + preset: :medium, + crf: 18, + ) + + See https://ffmpeg.org/ffmpeg-codecs.html#libx264_002c-libx264rgb for more information about the options. + """ + + @type profile :: :baseline | :main | :high + @type preset :: :slow | :medium + @type qrange :: 0..51 + @type crf :: qrange() + + @type opts :: [ + profile: profile(), + preset: preset(), + crf: qrange() + ] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + force_format: :yuv420p, + type: :video + } + + defstruct name: "libx264", + opts: [], + force_format: :yuv420p, + type: :video + + @doc """ + Construct a new libx264 (H.264) encoder. + + See module documentation for usage. + """ + @spec new(opts()) :: t() + def new(opts) do + %__MODULE__{ + opts: Keyword.take(opts, [:profile, :preset, :crf]) + } + end +end diff --git a/lib/philomena_media/libavfilter/endpoint.ex b/lib/philomena_media/libavfilter/endpoint.ex new file mode 100644 index 00000000..101598c8 --- /dev/null +++ b/lib/philomena_media/libavfilter/endpoint.ex @@ -0,0 +1,58 @@ +defmodule PhilomenaMedia.Libavfilter.Endpoint do + @moduledoc """ + Represents an endpoint vertex of the filter graph. Processing starts or stops here. + + An endpoint which has no input but produces one output is a source. An endpoint which has one + input but produces no output is a sink. + + See https://ffmpeg.org/ffmpeg-filters.html#Filtergraph-description for more information. + """ + + @type index :: non_neg_integer() + @type pad_type :: PhilomenaMedia.Libavfilter.FilterNode.pad_type() + + @type t :: %__MODULE__{ + name: nil, + opts: [], + inputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list(), + outputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list(), + index: index() + } + + @derive [PhilomenaMedia.Libavfilter.FilterNode] + defstruct name: nil, + opts: [], + inputs: [], + outputs: [], + index: 0 + + @doc """ + Create a new source endpoint with the given pad type. + + By default, this corresponds to stream index 0. Has one output pad with name `source`. + + See the moduledoc for `m:PhilomenaMedia.Libavfilter.FilterGraph` for a usage example. + """ + @spec new_source(index(), pad_type()) :: t() + def new_source(index \\ 0, pad_type) do + %__MODULE__{ + outputs: [source: pad_type], + index: index + } + end + + @doc """ + Create a new sink endpoint with the given pad type. + + By default, this corresponds to stream index 0. Has one input pad with name `sink`. + + See the moduledoc for `m:PhilomenaMedia.Libavfilter.FilterGraph` for a usage example. + """ + @spec new_sink(index(), pad_type()) :: t() + def new_sink(index \\ 0, pad_type) do + %__MODULE__{ + inputs: [sink: pad_type], + index: index + } + end +end diff --git a/lib/philomena_media/libavfilter/filter_graph.ex b/lib/philomena_media/libavfilter/filter_graph.ex new file mode 100644 index 00000000..bbbae349 --- /dev/null +++ b/lib/philomena_media/libavfilter/filter_graph.ex @@ -0,0 +1,194 @@ +defmodule PhilomenaMedia.Libavfilter.FilterGraph do + @moduledoc """ + Represents a complex filter graph. + + ## Example + + {graph, [source, palettegen, paletteuse, sink]} = + FilterGraph.new([ + Endpoint.new_source(:video), + Palettegen.new(stats_mode: :single), + Paletteuse.new(new: true), + Endpoint.new_sink(:video) + ]) + + graph + |> FilterGraph.connect({source, :source}, {palettegen, :source}) + |> FilterGraph.connect({source, :source}, {paletteuse, :source}) + |> FilterGraph.connect({palettegen, :result}, {paletteuse, :palette}) + |> FilterGraph.connect({paletteuse, :result}, {sink, :sink}) + + This creates the following conceptual graph: + + source --> palettegen + | | + | | + | v + --------paletteuse --> sink + + See https://ffmpeg.org/ffmpeg-filters.html#Filtergraph-description for more information. + """ + + alias PhilomenaMedia.Libavfilter.FilterNode + alias PhilomenaMedia.Libavfilter.Endpoint + + @type t :: %__MODULE__{ + forward_adjacency: %{tagged_vertex() => MapSet.t()}, + reverse_adjacency: %{tagged_vertex() => MapSet.t()}, + vertices: %{integer() => struct()}, + index: integer() + } + + @type vertex :: integer() + @type pad_name :: FilterNode.pad_name() + @type pad :: {vertex(), pad_name()} + + @type pad_index :: integer() + @type tagged_vertex :: {vertex(), pad_index()} + + defstruct forward_adjacency: %{}, + reverse_adjacency: %{}, + vertices: %{}, + index: 0 + + @doc """ + Creates a new filtergraph instance with an optional list of vertices to add to the graph. + + See the moduledoc for a full example. + """ + @spec new([struct()]) :: {t(), [vertex()]} + def new(nodes \\ []) do + add(%__MODULE__{}, nodes) + end + + @doc """ + Adds the specified list of vertices to the graph. + + Returns the updated filtergraph structure and a list of vertices. + See the moduledoc for a full example. + """ + @spec add(t(), [struct()]) :: {t(), [vertex()]} + def add(g, nodes) do + {vertices, g} = + Enum.map_reduce(nodes, g, fn node, acc -> + { + acc.index, + %{acc | vertices: Map.put(acc.vertices, acc.index, node), index: acc.index + 1} + } + end) + + {g, vertices} + end + + @doc """ + Connects two vertex references together with the given pad name. + + Returns the updated filtergraph structure. + See the moduledoc for a full example. + """ + @spec connect(t(), pad(), pad()) :: t() + def connect(g, {output_vert, output_name}, {input_vert, input_name}) do + output_node = Map.fetch!(g.vertices, output_vert) + output_pad = {output_vert, pad_name_to_index!(FilterNode.outputs(output_node), output_name)} + + input_node = Map.fetch!(g.vertices, input_vert) + input_pad = {input_vert, pad_name_to_index!(FilterNode.inputs(input_node), input_name)} + + forward_adjacency = + Map.update(g.forward_adjacency, output_pad, MapSet.new([input_pad]), fn v -> + MapSet.put(v, input_pad) + end) + + reverse_adjacency = + Map.update(g.reverse_adjacency, input_pad, MapSet.new([output_pad]), fn v -> + MapSet.put(v, output_pad) + end) + + %{g | forward_adjacency: forward_adjacency, reverse_adjacency: reverse_adjacency} + end + + @spec pad_name_to_index!(FilterNode.pad_list(), FilterNode.pad_name()) :: pad_index() + defp pad_name_to_index!(pads, pad_name) do + pads + |> Enum.find_index(fn {name, _type} -> name == pad_name end) + |> case do + nil -> + raise "Pad #{inspect(pad_name)} not found in list #{inspect(pads)}" + + value -> + value + end + end + + @doc """ + Convert the filtergraph to a textual representation. + """ + @spec to_graph(t()) :: String.t() + def to_graph(g) do + g.vertices + |> Enum.map(fn + {_vert, %Endpoint{}} -> + [] + + {vert, node} -> + [ + incoming_pads!(g, vert), + FilterNode.name(node), + encode_opts!(node), + outgoing_pads!(g, vert), + ";" + ] + end) + |> IO.iodata_to_binary() + end + + @spec incoming_pad!(t(), tagged_vertex()) :: iodata() + defp incoming_pad!(g, {target_vert, target_index}) do + [{source_vert, source_index}] = + g.reverse_adjacency + |> Map.fetch!({target_vert, target_index}) + |> MapSet.to_list() + + case g.vertices[source_vert] do + %_{index: index} -> + "[#{index}:v]" + + _ -> + "[p#{source_vert}_#{source_index}:v]" + end + end + + @spec incoming_pads!(t(), vertex()) :: iodata() + defp incoming_pads!(g, target_vert) do + g.vertices + |> Map.fetch!(target_vert) + |> FilterNode.inputs() + |> Enum.with_index() + |> Enum.map(fn {_name, index} -> incoming_pad!(g, {target_vert, index}) end) + end + + @spec outgoing_pad!(t(), tagged_vertex()) :: iodata() + defp outgoing_pad!(_g, {source_vert, source_index}) do + "[p#{source_vert}_#{source_index}:v]" + end + + @spec outgoing_pads!(t(), vertex()) :: iodata() + defp outgoing_pads!(g, source_vert) do + g.vertices + |> Map.fetch!(source_vert) + |> FilterNode.outputs() + |> Enum.with_index() + |> Enum.map(fn {_name, index} -> outgoing_pad!(g, {source_vert, index}) end) + end + + @spec encode_opts!(struct()) :: iodata() + defp encode_opts!(filter_node) + + defp encode_opts!(%_{opts: opts}) when opts == %{} do + "" + end + + defp encode_opts!(%_{opts: opts}) do + ["=", Enum.map_join(opts, ":", fn {k, v} -> "#{k}=#{v}" end)] + end +end diff --git a/lib/philomena_media/libavfilter/filter_node.ex b/lib/philomena_media/libavfilter/filter_node.ex new file mode 100644 index 00000000..9da69ec7 --- /dev/null +++ b/lib/philomena_media/libavfilter/filter_node.ex @@ -0,0 +1,62 @@ +defprotocol PhilomenaMedia.Libavfilter.FilterNode do + @doc """ + The name of this filter, as a string, or nil if the filter is an endpoint. + """ + @spec name(t) :: String.t() | nil + def name(t) + + @doc """ + A list of options passed to this filter, if any. + + ## Example + + iex> FilterNode.opts(%Paletteuse{...}) + [dither: :bayer, bayer_scale: 5] + + """ + @spec opts(t) :: keyword() + def opts(t) + + @type pad_name :: atom() + @type pad_type :: :video + @type pad_list :: [{pad_name(), pad_type()}] + + @doc """ + A list of inputs passed to this filter, if any. + + > #### Info {: .info} + > + > Inputs are given names for clarity, but libavfilter addresses inputs + > by *numeric index*, not by name, so this must not be placed into a map. + + ## Example + + iex> FilterNode.inputs(%Paletteuse{...}) + [source: :video, palette: :video] + """ + @spec inputs(t) :: pad_list() + def inputs(t) + + @doc """ + A list of outputs generated by this filter, if any. + + > #### Info {: .info} + > + > Outputs are given names for clarity, but libavfilter addresses outputs + > by *numeric index*, not by name, so this must not be placed into a map. + + ## Example + + iex> FilterNode.outputs(%Scale{...}) + [result: :video] + """ + @spec outputs(t) :: pad_list() + def outputs(t) +end + +defimpl PhilomenaMedia.Libavfilter.FilterNode, for: Any do + def name(t), do: t.name + def opts(t), do: t.opts + def inputs(t), do: t.inputs + def outputs(t), do: t.outputs +end diff --git a/lib/philomena_media/libavfilter/palettegen.ex b/lib/philomena_media/libavfilter/palettegen.ex new file mode 100644 index 00000000..852fb2e5 --- /dev/null +++ b/lib/philomena_media/libavfilter/palettegen.ex @@ -0,0 +1,58 @@ +defmodule PhilomenaMedia.Libavfilter.Palettegen do + @moduledoc """ + Represents the `palettegen` filter, which takes a video input and generates a video output. + + The palette for each frame processed is generated using the median cut algorithm. + + Has one input pad with name `source`. Has one output pad with name `result`. + + ## Example with all options + + Palettegen.new( + max_colors: 255, + reserve_transparent: true, + stats_mode: :diff + ) + + See https://ffmpeg.org/ffmpeg-filters.html#palettegen for more information about the options. + """ + + @type max_colors :: 0..256 + @type reserve_transparent :: boolean() + @type stats_mode :: :full | :diff | :single + + @type opts :: [ + max_colors: max_colors(), + reserve_transparent: reserve_transparent(), + stats_mode: stats_mode() + ] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + inputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list(), + outputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list() + } + + @derive [PhilomenaMedia.Libavfilter.FilterNode] + defstruct name: "palettegen", + opts: [], + inputs: [source: :video], + outputs: [result: :video] + + @doc """ + Construct a new palettegen filter. + + See module documentation for usage. + """ + @spec new(opts()) :: t() + def new(opts) do + %__MODULE__{ + opts: [ + max_colors: Keyword.get(opts, :max_colors, 255), + reserve_transparent: Keyword.get(opts, :reserve_transparent, true), + stats_mode: Keyword.get(opts, :stats_mode, :diff) + ] + } + end +end diff --git a/lib/philomena_media/libavfilter/paletteuse.ex b/lib/philomena_media/libavfilter/paletteuse.ex new file mode 100644 index 00000000..703aa9c7 --- /dev/null +++ b/lib/philomena_media/libavfilter/paletteuse.ex @@ -0,0 +1,67 @@ +defmodule PhilomenaMedia.Libavfilter.Paletteuse do + @moduledoc """ + Represents the `paletteuse` filter, which takes two video inputs and generates a video output. + + The first input is the video stream, and the second input is the a 256-color palette. + Video colors are mapped onto the palette using a kd-tree quantization algorithm. + + Has two input pads with names `source` and `palette`. Has one output pad with name `result`. + + ## Example with all options + + Paletteuse.new( + dither: :bayer, + bayer_scale: 5, + diff_mode: :rectangle, + new: false, + alpha_threshold: 255 + ) + + See https://ffmpeg.org/ffmpeg-filters.html#paletteuse for more information about the options. + """ + + @type dither :: :bayer | :none + @type bayer_scale :: 0..5 + @type diff_mode :: :none | :rectangle + @type new :: boolean() + @type alpha_threshold :: 0..255 + + @type opts :: [ + dither: dither(), + bayer_scale: bayer_scale(), + diff_mode: diff_mode(), + new: new(), + alpha_threshold: alpha_threshold() + ] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + inputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list(), + outputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list() + } + + @derive [PhilomenaMedia.Libavfilter.FilterNode] + defstruct name: "paletteuse", + opts: [], + inputs: [source: :video, palette: :video], + outputs: [result: :video] + + @doc """ + Construct a new paletteuse filter. + + See module documentation for usage. + """ + @spec new(opts()) :: t() + def new(opts) do + %__MODULE__{ + opts: [ + dither: Keyword.get(opts, :dither, :bayer), + bayer_scale: Keyword.get(opts, :bayer_scale, 5), + diff_mode: Keyword.get(opts, :diff_mode, :rectangle), + new: Keyword.get(opts, :new, false), + alpha_threshold: Keyword.get(opts, :alpha_threshold, 255) + ] + } + end +end diff --git a/lib/philomena_media/libavfilter/scale.ex b/lib/philomena_media/libavfilter/scale.ex new file mode 100644 index 00000000..65fd2fa2 --- /dev/null +++ b/lib/philomena_media/libavfilter/scale.ex @@ -0,0 +1,61 @@ +defmodule PhilomenaMedia.Libavfilter.Scale do + @moduledoc """ + Represents the `scale` filter, which takes a video input and generates a video output. + + Has one input pad with name `source`. Has one output pad with name `result`. + + ## Example with all options + + Scale.new( + width: 250, + height: 250, + force_original_aspect_ratio: :decrease, + force_divisible_by: 2 + ) + + See https://ffmpeg.org/ffmpeg-filters.html#scale-1 for more information about the options. + """ + + @type dimension :: integer() + @type width :: dimension() + @type height :: dimension() + @type force_original_aspect_ratio :: :disable | :decrease | :increase + @type force_divisible_by :: pos_integer() + + @type opts :: [ + width: width(), + height: height(), + force_original_aspect_ratio: force_original_aspect_ratio(), + force_divisible_by: force_divisible_by() + ] + + @type t :: %__MODULE__{ + name: String.t(), + opts: opts(), + inputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list(), + outputs: PhilomenaMedia.Libavfilter.FilterNode.pad_list() + } + + @derive [PhilomenaMedia.Libavfilter.FilterNode] + defstruct name: "scale", + opts: [], + inputs: [source: :video], + outputs: [result: :video] + + @doc """ + Construct a new scale filter. + + See module documentation for usage. + """ + @spec new(opts()) :: t() + def new(opts) do + %__MODULE__{ + opts: [ + width: Keyword.fetch!(opts, :width), + height: Keyword.fetch!(opts, :height), + force_original_aspect_ratio: Keyword.get(opts, :force_original_aspect_ratio, :disable), + force_divisible_by: Keyword.get(opts, :force_divisible_by, 1) + ] + } + end +end