diff --git a/lib/algora/clipper.ex b/lib/algora/clipper.ex index 7bd4cb5..7a10e78 100644 --- a/lib/algora/clipper.ex +++ b/lib/algora/clipper.ex @@ -12,7 +12,8 @@ defmodule Algora.Clipper do %{ acc | timeline: [ - %ExM3U8.Tags.MediaInit{uri: Storage.to_absolute(:video, video.uuid, uri)} | acc.timeline + %ExM3U8.Tags.MediaInit{uri: Storage.to_absolute(:video, video.uuid, uri)} + | acc.timeline ] } @@ -87,60 +88,82 @@ defmodule Algora.Clipper do end def create_combined_local_clips(video, clips_params) do - # Generate a unique filename for the combined clip - filename = generate_combined_clip_filename(video, clips_params) - output_path = Path.join(System.tmp_dir(), "#{filename}.mp4") + # Generate a unique filename for the combined clip + filename = generate_combined_clip_filename(video, clips_params) + output_path = Path.join(System.tmp_dir(), "#{filename}.mp4") - # Create a temporary file for the complex filter - filter_path = Path.join(System.tmp_dir(), "#{filename}_filter.txt") - File.write!(filter_path, create_filter_complex(clips_params)) + # Create a temporary file for the complex filter + filter_path = Path.join(System.tmp_dir(), "#{filename}_filter.txt") + File.write!(filter_path, create_filter_complex(clips_params)) - # Construct the FFmpeg command - ffmpeg_cmd = [ - "-y", - "-i", video.url, - "-filter_complex_script", filter_path, - "-map", "[v]", - "-map", "[a]", - "-c:v", "libx264", - "-c:a", "aac", - output_path - ] + # Construct the FFmpeg command + ffmpeg_cmd = [ + "-y", + "-i", + video.url, + "-filter_complex_script", + filter_path, + "-map", + "[v]", + "-map", + "[a]", + "-c:v", + "libx264", + "-c:a", + "aac", + output_path + ] - # Execute the FFmpeg command - case System.cmd("ffmpeg", ffmpeg_cmd, stderr_to_stdout: true) do - {_, 0} -> - File.rm(filter_path) - {:ok, output_path} - {error, _} -> - File.rm(filter_path) - {:error, "FFmpeg error: #{error}"} - end + # Execute the FFmpeg command + case System.cmd("ffmpeg", ffmpeg_cmd, stderr_to_stdout: true) do + {_, 0} -> + File.rm(filter_path) + {:ok, output_path} + + {error, _} -> + File.rm(filter_path) + {:error, "FFmpeg error: #{error}"} end + end - defp generate_combined_clip_filename(video, clips_params) do - clip_count = map_size(clips_params) - total_duration = Enum.sum(Enum.map(clips_params, fn {_, clip} -> - Library.from_hhmmss(clip["clip_to"]) - Library.from_hhmmss(clip["clip_from"]) - end)) - Slug.slugify("#{video.title}-#{clip_count}clips-#{total_duration}s") - end + defp generate_combined_clip_filename(video, clips_params) do + clip_count = map_size(clips_params) - defp create_filter_complex(clips_params) do - {filter_complex, _} = clips_params + total_duration = + Enum.sum( + Enum.map(clips_params, fn {_, clip} -> + Library.from_hhmmss(clip["clip_to"]) - Library.from_hhmmss(clip["clip_from"]) + end) + ) + + Slug.slugify("#{video.title}-#{clip_count}clips-#{total_duration}s") + end + + defp create_filter_complex(clips_params) do + {filter_complex, _} = + clips_params |> Enum.sort_by(fn {key, _} -> key end) |> Enum.reduce({"", 0}, fn {_, clip}, {acc, index} -> from = Library.from_hhmmss(clip["clip_from"]) to = Library.from_hhmmss(clip["clip_to"]) - clip_filter = "[0:v]trim=start=#{from}:end=#{to},setpts=PTS-STARTPTS[v#{index}]; " <> - "[0:a]atrim=start=#{from}:end=#{to},asetpts=PTS-STARTPTS[a#{index}];\n" + + clip_filter = + "[0:v]trim=start=#{from}:end=#{to},setpts=PTS-STARTPTS[v#{index}]; " <> + "[0:a]atrim=start=#{from}:end=#{to},asetpts=PTS-STARTPTS[a#{index}];\n" + {acc <> clip_filter, index + 1} end) - clip_count = map_size(clips_params) - video_concat = Enum.map_join(0..clip_count-1, "", fn i -> "[v#{i}]" end) <> "concat=n=#{clip_count}:v=1:a=0[v];\n" - audio_concat = Enum.map_join(0..clip_count-1, "", fn i -> "[a#{i}]" end) <> "concat=n=#{clip_count}:v=0:a=1[a]" + clip_count = map_size(clips_params) - filter_complex <> video_concat <> audio_concat - end + video_concat = + Enum.map_join(0..(clip_count - 1), "", fn i -> "[v#{i}]" end) <> + "concat=n=#{clip_count}:v=1:a=0[v];\n" + + audio_concat = + Enum.map_join(0..(clip_count - 1), "", fn i -> "[a#{i}]" end) <> + "concat=n=#{clip_count}:v=0:a=1[a]" + + filter_complex <> video_concat <> audio_concat + end end diff --git a/lib/algora_web/live/video_clipper_live.ex b/lib/algora_web/live/video_clipper_live.ex index 278f786..38695bb 100644 --- a/lib/algora_web/live/video_clipper_live.ex +++ b/lib/algora_web/live/video_clipper_live.ex @@ -9,103 +9,165 @@ defmodule AlgoraWeb.VideoClipperLive do def render(assigns) do ~H"""
-
-

Clip editor

- <.simple_form for={@form} phx-change="update_form" phx-submit="create_video"> -
-
-
- <.input - field={@form[:livestream_id]} - type="select" - options={Enum.map(@livestreams, &{&1.title, &1.id})} - value={@selected_livestream && @selected_livestream.id} - prompt="Select livestream" - class="w-full bg-white/5 border border-white/15 rounded p-2 appearance-none" - /> - <.error :for={error <- @form[:livestream_id].errors}> - <%=error %> - -
-
- <%= if @selected_livestream do %> -
- <.live_component - module={PlayerComponent} - id="preview-player" - video={@selected_livestream} - current_time={@preview_clip && @preview_clip.start || 0} - end_time={@preview_clip && @preview_clip.end || nil} - current_user={@current_user} - /> -
- <% else %> - - <% end %> -
- <.input field={@form[:title]} type="text" label="Title" class="w-full bg-white/5 border border-white/15 rounded p-2" /> - <.input field={@form[:description]} type="textarea" label="Description" class="w-full bg-white/5 border border-white/15 rounded p-2 h-24" /> +
+

Clip editor

+ <.simple_form for={@form} phx-change="update_form" phx-submit="create_video"> +
+
+
+ <.input + field={@form[:livestream_id]} + type="select" + options={Enum.map(@livestreams, &{&1.title, &1.id})} + value={@selected_livestream && @selected_livestream.id} + prompt="Select livestream" + class="w-full bg-white/5 border border-white/15 rounded p-2 appearance-none" + /> + <.error :for={error <- @form[:livestream_id].errors}> + <%= error %> +
-
+
+ <%= if @selected_livestream do %> +
+ <.live_component + module={PlayerComponent} + id="preview-player" + video={@selected_livestream} + current_time={(@preview_clip && @preview_clip.start) || 0} + end_time={(@preview_clip && @preview_clip.end) || nil} + current_user={@current_user} + /> +
+ <% else %> + + <% end %> +
+ <.input + field={@form[:title]} + type="text" + label="Title" + class="w-full bg-white/5 border border-white/15 rounded p-2" + /> + <.input + field={@form[:description]} + type="textarea" + label="Description" + class="w-full bg-white/5 border border-white/15 rounded p-2 h-24" + /> +
+
<%= for {clip, index} <- Enum.with_index(@clips) do %>

Clip <%= index + 1 %>

-
- <.input type="text" name={"video_clipper[clips][#{index}][clip_from]"} value={clip.clip_from} class="w-full bg-white/5 border border-white/15 rounded p-1 text-sm" phx-debounce="300" /> + <.input + type="text" + name={"video_clipper[clips][#{index}][clip_from]"} + value={clip.clip_from} + class="w-full bg-white/5 border border-white/15 rounded p-1 text-sm" + phx-debounce="300" + /> <%= for error <- (clip.errors[:clip_from] || []) do %> <.error><%= error %> <% end %>
- <.input type="text" name={"video_clipper[clips][#{index}][clip_to]"} value={clip.clip_to} class="w-full bg-white/5 border border-white/15 rounded p-1 text-sm" phx-debounce="300" /> + <.input + type="text" + name={"video_clipper[clips][#{index}][clip_to]"} + value={clip.clip_to} + class="w-full bg-white/5 border border-white/15 rounded p-1 text-sm" + phx-debounce="300" + /> <%= for error <- (clip.errors[:clip_to] || []) do %> <.error><%= error %> <% end %> -
+
- <.button type="button" phx-click="preview_clip" phx-value-index={index} class="flex-grow" disabled={clip.errors != %{}}>Preview Clip <%= index + 1 %> + <.button + type="button" + phx-click="preview_clip" + phx-value-index={index} + class="flex-grow" + disabled={clip.errors != %{}} + > + Preview Clip <%= index + 1 %> +
- <.button type="button" phx-click="clip_action" phx-value-action="move_up"phx-value-index={index}> + <.button + type="button" + phx-click="clip_action" + phx-value-action="move_up" + phx-value-index={index} + > - <.button type="button" phx-click="clip_action" phx-value-action="move_down" phx-value-index={index}> + <.button + type="button" + phx-click="clip_action" + phx-value-action="move_down" + phx-value-index={index} + >
<% end %> - <.button type="button" phx-click="clip_action" phx-value-action="add" phx-value-index={-1} class="w-full">+ Add new clip -
+ <.button + type="button" + phx-click="clip_action" + phx-value-action="add" + phx-value-index={-1} + class="w-full" + > + + Add new clip +
- <:actions> -
- <.button type="submit" disabled={@processing} class="w-full rounded-xl p-3 font-semibold"> - <%= if @processing, do: "Creating video..", else: "Create video" %> - - - <%= if @processing do %> -
- <%= @progress.stage %> (<%= @progress.current %>/<%= @progress.total %>) -
-
-
-
- <% end %> -
- -
+ <:actions> +
+ <.button + type="submit" + disabled={@processing} + class="w-full rounded-xl p-3 font-semibold" + > + <%= if @processing, do: "Creating video..", else: "Create video" %> + + + <%= if @processing do %> +
+ <%= @progress.stage %> (<%= @progress.current %>/<%= @progress.total %>) +
+
+
+
+
+ <% end %> +
+ +
+
""" end @@ -132,40 +194,48 @@ defmodule AlgoraWeb.VideoClipperLive do end def handle_event("update_form", %{"video_clipper" => params}, socket) do + socket = + if params["livestream_id"] && + params["livestream_id"] != to_string(socket.assigns.selected_livestream.id) do + new_livestream = + Enum.find( + socket.assigns.livestreams, + &(&1.id == String.to_integer(params["livestream_id"])) + ) - socket = if params["livestream_id"] && params["livestream_id"] != to_string(socket.assigns.selected_livestream.id) do - new_livestream = Enum.find(socket.assigns.livestreams, &(&1.id == String.to_integer(params["livestream_id"]))) + if new_livestream do + send_update(PlayerComponent, + id: "preview-player", + video: new_livestream, + current_time: 0, + end_time: nil, + current_user: socket.assigns.current_user + ) - if new_livestream do - send_update(PlayerComponent, - id: "preview-player", - video: new_livestream, - current_time: 0, - end_time: nil, - current_user: socket.assigns.current_user - ) - - socket + socket |> assign(selected_livestream: new_livestream) |> assign(preview_clip: nil) - |> assign(clips: []) # Clear the clips + # Clear the clips + |> assign(clips: []) + else + socket + end else socket end - else - socket - end # Update clips only if the livestream hasn't changed - updated_clips = if params["livestream_id"] == to_string(socket.assigns.selected_livestream.id) do - update_clips_from_params(params["clips"] || %{}) - else - [] - end + updated_clips = + if params["livestream_id"] == to_string(socket.assigns.selected_livestream.id) do + update_clips_from_params(params["clips"] || %{}) + else + [] + end changeset = params |> change_video_clipper() |> Map.put(:action, :validate) - socket = socket + socket = + socket |> assign(clips: updated_clips) |> assign_form(changeset) @@ -177,6 +247,7 @@ defmodule AlgoraWeb.VideoClipperLive do |> Enum.sort_by(fn {key, _} -> key end) |> Enum.map(fn {_, clip} -> errors = validate_clip_times(clip["clip_from"], clip["clip_to"]) + %{ clip_from: clip["clip_from"] || "", clip_to: clip["clip_to"] || "", @@ -204,12 +275,13 @@ defmodule AlgoraWeb.VideoClipperLive do defp validate_clips(changeset) do clips = Ecto.Changeset.get_field(changeset, :clips) || %{} - errors = Enum.reduce(clips, [], fn {_index, clip}, acc -> - case validate_clip_times(clip["clip_from"], clip["clip_to"]) do - %{} -> acc - errors -> [errors | acc] - end - end) + errors = + Enum.reduce(clips, [], fn {_index, clip}, acc -> + case validate_clip_times(clip["clip_from"], clip["clip_to"]) do + %{} -> acc + errors -> [errors | acc] + end + end) case errors do [] -> changeset @@ -221,10 +293,13 @@ defmodule AlgoraWeb.VideoClipperLive do case {parse_seconds(from), parse_seconds(to)} do {{:ok, from_seconds}, {:ok, to_seconds}} when from_seconds < to_seconds -> %{} + {{:ok, _}, {:ok, _}} -> %{clip_to: ["End time must be after start time"]} + {{:error, _}, _} -> %{clip_from: ["Invalid time. Use HH:MM:SS"]} + {_, {:error, _}} -> %{clip_to: ["Invalid time. Use HH:MM:SS"]} end @@ -259,17 +334,25 @@ defmodule AlgoraWeb.VideoClipperLive do index = String.to_integer(index) clips = socket.assigns.clips - updated_clips = case action do - "add" -> clips ++ [%{clip_from: "", clip_to: "", errors: %{}}] - "remove" -> List.delete_at(clips, index) - "move_up" when index > 0 -> - {clip, clips} = List.pop_at(clips, index) - List.insert_at(clips, index - 1, clip) - "move_down" when index < length(clips) - 1 -> - {clip, clips} = List.pop_at(clips, index) - List.insert_at(clips, index + 1, clip) - _ -> clips - end + updated_clips = + case action do + "add" -> + clips ++ [%{clip_from: "", clip_to: "", errors: %{}}] + + "remove" -> + List.delete_at(clips, index) + + "move_up" when index > 0 -> + {clip, clips} = List.pop_at(clips, index) + List.insert_at(clips, index - 1, clip) + + "move_down" when index < length(clips) - 1 -> + {clip, clips} = List.pop_at(clips, index) + List.insert_at(clips, index + 1, clip) + + _ -> + clips + end {:noreply, assign(socket, clips: updated_clips)} end @@ -285,8 +368,10 @@ defmodule AlgoraWeb.VideoClipperLive do video = socket.assigns.selected_livestream update_player(socket, video, start, end_time, "Previewing Clip #{index + 1}") {:noreply, assign(socket, preview_clip: %{start: start, end: end_time})} + _ -> - {:noreply, put_flash(socket, :error, "An unexpected error occurred while previewing the clip")} + {:noreply, + put_flash(socket, :error, "An unexpected error occurred while previewing the clip")} end else {:noreply, put_flash(socket, :error, "Cannot preview an invalid clip")} @@ -300,10 +385,11 @@ defmodule AlgoraWeb.VideoClipperLive do @impl true def handle_info({:processing_complete, video}, socket) do - {:noreply, socket - |> put_flash(:info, "Video created successfully!") - |> assign(processing: false, progress: nil) - |> push_redirect(to: ~p"/#{video.channel_handle}/#{video.id}")} + {:noreply, + socket + |> put_flash(:info, "Video created successfully!") + |> assign(processing: false, progress: nil) + |> push_redirect(to: ~p"/#{video.channel_handle}/#{video.id}")} end @impl true @@ -312,13 +398,16 @@ defmodule AlgoraWeb.VideoClipperLive do if changeset.valid? do # Set initial processing state - socket = assign(socket, - processing: true, - progress: %{stage: "Initializing", current: 0, total: 100} - ) + socket = + assign(socket, + processing: true, + progress: %{stage: "Initializing", current: 0, total: 100} + ) # Start the video creation process in a separate process - lv = self() # capture the LiveView PID + # capture the LiveView PID + lv = self() + Task.start(fn -> case create_video(params, socket, lv) do {:ok, video} -> send(lv, {:processing_complete, video}) @@ -338,9 +427,10 @@ defmodule AlgoraWeb.VideoClipperLive do current_user = socket.assigns.current_user # Check for clip errors - clip_errors = clips - |> update_clips_from_params() - |> Enum.flat_map(fn clip -> Map.to_list(clip.errors) end) + clip_errors = + clips + |> update_clips_from_params() + |> Enum.flat_map(fn clip -> Map.to_list(clip.errors) end) if Enum.empty?(clip_errors) do # Send initial progress update @@ -360,32 +450,37 @@ defmodule AlgoraWeb.VideoClipperLive do new_video = Library.init_mp4!(upload_entry, combined_clip_path, current_user) send(lv, {:progress_update, %{stage: "Updating video", current: 3, total: 6}}) - {:ok, updated_video} = Library.update_video(new_video, %{ - title: params["title"] || "New Video", - description: params["description"], - visibility: :unlisted - }) + + {:ok, updated_video} = + Library.update_video(new_video, %{ + title: params["title"] || "New Video", + description: params["description"], + visibility: :unlisted + }) # Handle transmux progress updates with all three stages - processed_video = Library.transmux_to_hls(updated_video, fn progress_info -> - progress = case progress_info do - %{stage: :transmuxing, done: done, total: total} -> - # Transmuxing is stage 4 - current = 3 + (done / total) - %{stage: "Transmuxing", current: trunc(current), total: 6} + processed_video = + Library.transmux_to_hls(updated_video, fn progress_info -> + progress = + case progress_info do + %{stage: :transmuxing, done: done, total: total} -> + # Transmuxing is stage 4 + current = 3 + done / total + %{stage: "Transmuxing", current: trunc(current), total: 6} - %{stage: :persisting, done: done, total: total} -> - # Persisting is stage 5 - current = 4 + (done / total) - %{stage: "Persisting", current: trunc(current), total: 6} + %{stage: :persisting, done: done, total: total} -> + # Persisting is stage 5 + current = 4 + done / total + %{stage: "Persisting", current: trunc(current), total: 6} - %{stage: :generating_thumbnail, done: done, total: total} -> - # Generating thumbnail is stage 6 - current = 5 + (done / total) - %{stage: "Generating thumbnail", current: trunc(current), total: 6} - end - send(lv, {:progress_update, progress}) - end) + %{stage: :generating_thumbnail, done: done, total: total} -> + # Generating thumbnail is stage 6 + current = 5 + done / total + %{stage: "Generating thumbnail", current: trunc(current), total: 6} + end + + send(lv, {:progress_update, progress}) + end) # Clean up temporary file File.rm(combined_clip_path) @@ -406,13 +501,13 @@ defmodule AlgoraWeb.VideoClipperLive do end defp update_player(socket, video, current_time, end_time \\ nil, title \\ nil) do - send_update(PlayerComponent, - id: "preview-player", - video: video, - current_time: current_time, - end_time: end_time, - title: title, - current_user: socket.assigns.current_user - ) - end + send_update(PlayerComponent, + id: "preview-player", + video: video, + current_time: current_time, + end_time: end_time, + title: title, + current_user: socket.assigns.current_user + ) + end end