Skip to content

Commit

Permalink
Add video trim command
Browse files Browse the repository at this point in the history
  • Loading branch information
HEJOK254 committed Jun 16, 2024
1 parent f7fb264 commit d9e75ad
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 1 deletion.
41 changes: 40 additions & 1 deletion Commands/CommandManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
Expand All @@ -23,6 +24,40 @@ public class CommandManager
.WithDescription("Test command.")
.WithIntegrationTypes(ApplicationIntegrationType.UserInstall)
.WithContextTypes(interactionContextAll),

new SlashCommandBuilder()
.WithName("trim")
.WithDescription("Trim a video")
.WithIntegrationTypes(ApplicationIntegrationType.UserInstall)
.WithContextTypes(interactionContextUser)
.AddOption(new SlashCommandOptionBuilder()
.WithName("video")
.WithDescription("The video to trim")
.WithType(ApplicationCommandOptionType.Attachment)
.WithRequired(true))
.AddOption(new SlashCommandOptionBuilder()
.WithName("start")
.WithDescription("What time should the video start? [XXh XXm XXs XXms]")
.WithType(ApplicationCommandOptionType.String) // TODO: Change to ApplicationCommandOptionType.Time if added one day
.WithAutocomplete(true)
.WithMinLength(2) // The time cannot be expressed with less than 2 characters
.WithRequired(false))
.AddOption(new SlashCommandOptionBuilder()
.WithName("end")
.WithDescription("What time should the video end? [XXh XXm XXs XXms]")
.WithType(ApplicationCommandOptionType.String) // TODO: Change to ApplicationCommandOptionType.Time if added one day
.WithMinLength(2) // The time cannot be expressed with less than 2 characters
.WithRequired(false))
.AddOption(new SlashCommandOptionBuilder()
.WithName("message")
.WithDescription("A message to send with the video when it's trimmed")
.WithType(ApplicationCommandOptionType.String)
.WithRequired(false))
.AddOption(new SlashCommandOptionBuilder()
.WithName("ephemeral")
.WithDescription("If the video should be sent as a temporary message, that's only visible to you")
.WithType(ApplicationCommandOptionType.Boolean)
.WithRequired(false))
};
#endregion

Expand Down Expand Up @@ -60,7 +95,11 @@ private async Task SlashCommandHandlerAsync(SocketSlashCommand command)
switch (command.Data.Name)
{
case "test":
await command.RespondAsync("Test command executed!");
command.RespondAsync("Test command executed!");

Check warning on line 98 in Commands/CommandManager.cs

View workflow job for this annotation

GitHub Actions / build

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

Check warning on line 98 in Commands/CommandManager.cs

View workflow job for this annotation

GitHub Actions / build

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
break;

case "trim":
VideoUtils.TrimVideoAsync(command);

Check warning on line 102 in Commands/CommandManager.cs

View workflow job for this annotation

GitHub Actions / build

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

Check warning on line 102 in Commands/CommandManager.cs

View workflow job for this annotation

GitHub Actions / build

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
break;

// In case the command is not recognized by the bot
Expand Down
146 changes: 146 additions & 0 deletions Commands/VideoUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using Discord;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FFMpegCore;
using FFMpegCore.Pipes;
using System.Text.RegularExpressions;
using System.Net.Mail;
using System.Security.Cryptography.X509Certificates;

namespace QuickEdit.Commands;
public class VideoUtils
{
public static async Task TrimVideoAsync(SocketSlashCommand command)
{
// Get arguments
string? trimStartString = command.Data.Options.FirstOrDefault(x => x.Name == "start")?.Value as string;
string? trimEndString = command.Data.Options.FirstOrDefault(x => x.Name == "end")?.Value as string;
string message = command.Data.Options.FirstOrDefault(x => x.Name == "message")?.Value as string ?? string.Empty;
bool ephemeral = command.Data.Options.FirstOrDefault(x => x.Name == "ephemeral")?.Value as bool? ?? false;
var attachment = command.Data.Options.FirstOrDefault(x => x.Name == "video")?.Value as Discord.Attachment;

string videoInputPath = "./tmp/input.mp4"; // Normally, I would use Path.GetTempFileName(), but FFMpegCore doesn't seem to
string videoOutputPath = "./tmp/output.mp4"; // like the .tmp file extension (or anything other than .mp4) as far as i know

// Achknowledge the command
await command.DeferAsync(ephemeral);

// The video can't be trimmed if both start and end times are null / 0
if (trimStartString == null && trimEndString == null) {
await command.FollowupAsync("You must provide a start or end time to trim the video.", ephemeral: true);
return;
}

// The attachment should never be null, as it's a required option
if (attachment == null)
{
await command.FollowupAsync("An error occurred while trying to process the video. Please try again.", ephemeral: true);
await Program.LogAsync("VideoUtils", "Attachment was null in TrimVideoAsync", LogSeverity.Error);
return;
}

// Reject incorrect formats
if (attachment.ContentType != "video/mp4")
{
await command.FollowupAsync("Invalid video format. Please provide an MP4 file.", ephemeral: true);
return;
}

TimeSpan? trimStart = await GetTrimTime(trimStartString, command);
TimeSpan? trimEnd = await GetTrimTime(trimEndString, command);

// The GetTrimTime method returns null on error
if (trimStart == null || trimEnd == null) return;

// Check if the directory, where the video is supposed to be exists
if (!Directory.Exists("./tmp"))
{
Directory.CreateDirectory("./tmp");
}

await DownloadVideo(attachment.Url, videoInputPath);

// Replace the end time with the video's duration if it's 0, greater than the video's duration, or smaller than the start time
if (trimEnd <= trimStart)
{
var mediaInfo = await FFProbe.AnalyseAsync(videoInputPath);
trimEnd = mediaInfo.Duration;
}

// Process and send video
await FFMpeg.SubVideoAsync(videoInputPath, videoOutputPath, (TimeSpan)trimStart, (TimeSpan)trimEnd); // Need to convert the TimeSpans since the value is nullable
await command.FollowupWithFileAsync(videoOutputPath, attachment.Filename, message, ephemeral: ephemeral);

// Clean up
File.Delete(videoInputPath);
File.Delete(videoOutputPath);
}

private static async Task DownloadVideo(string uri, string path) {
using var client = new HttpClient();
using var s = await client.GetStreamAsync(uri);
using var fs = new FileStream(path, FileMode.OpenOrCreate);
await s.CopyToAsync(fs);
fs.Close();
}

private static async Task<TimeSpan?> GetTrimTime(string? timeString, SocketSlashCommand command) {
if (timeString == null)
{
// This will later be replaced with the video's duration
return TimeSpan.Zero;
}
else
{
try
{
return TimeSpanFromHMS(timeString);
}
catch
{
await command.FollowupAsync("Invalid time format. Please provide a valid time format (XXh XXm XXs XXms).", ephemeral: true);
await Program.LogAsync("VideoUtils", $"Invalid time format in TrimVideoAsync (received: {timeString})", LogSeverity.Verbose);
return null;
}
}
}

/// <summary>
/// Parses a string in the format 'XXh XXm XXs XXms' into a TimeSpan object
/// </summary>
/// <param name="input">Input string to parse, in format [XXh XXm XXs]</param>
/// <returns>The parsed TimeSpan</returns>
/// <exception cref="ArgumentException">Thrown when the input string is in an invalid format</exception>
public static TimeSpan TimeSpanFromHMS(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new ArgumentException("Input string is not in a valid format");
}

// Define the regular expression pattern to match hours, minutes, and seconds
string pattern = @"((?<milliseconds>\d+)ms)?\s*((?<hours>\d+)h)?\s*((?<minutes>\d+)m|min)?\s*((?<seconds>\d+)s)?";

// Match the input string with the pattern
var match = Regex.Match(input, pattern, RegexOptions.IgnoreCase);

// Check if at least one component (hours, minutes, or seconds) is present
if (!match.Groups["hours"].Success && !match.Groups["minutes"].Success && !match.Groups["seconds"].Success && !match.Groups["milliseconds"].Success)
{
throw new ArgumentException("Input string is not in a valid format");
}

// Extract the matched groups
int hours = match.Groups["hours"].Success ? int.Parse(match.Groups["hours"].Value) : 0;
int minutes = match.Groups["minutes"].Success ? int.Parse(match.Groups["minutes"].Value) : 0;
int seconds = match.Groups["seconds"].Success ? int.Parse(match.Groups["seconds"].Value) : 0;
int milliseconds = match.Groups["milliseconds"].Success ? int.Parse(match.Groups["milliseconds"].Value) : 0;

// Create and return the TimeSpan object
return new TimeSpan(days: 0, hours, minutes, seconds, milliseconds);
}
}
1 change: 1 addition & 0 deletions Discord QuickEdit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.15.0" />
<PackageReference Include="FFMpegCore" Version="5.1.0" />
</ItemGroup>

</Project>

0 comments on commit d9e75ad

Please sign in to comment.