2025-07-23 23:55:56 -05:00
|
|
|
namespace StreamShorts.Console.Commands;
|
|
|
|
|
|
2025-08-19 16:25:15 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// The default command for processing video streams to create short clips.
|
|
|
|
|
/// </summary>
|
2025-07-29 18:26:22 -05:00
|
|
|
internal sealed class DefaultCommand(
|
2025-07-23 23:55:56 -05:00
|
|
|
IFileSystem fileSystem,
|
2025-07-24 11:22:15 -05:00
|
|
|
IAnsiConsole console,
|
2025-07-27 23:33:05 -05:00
|
|
|
IAudioExtractor audioExtractor,
|
2025-07-31 00:10:27 -05:00
|
|
|
ITranscriber transcriber,
|
2025-08-12 00:17:37 -05:00
|
|
|
ITranscriptAnalyzer transcriptAnalyzer,
|
|
|
|
|
IShortsCreator shortsCreator,
|
|
|
|
|
TimeProvider timeProvider
|
2025-07-23 23:55:56 -05:00
|
|
|
) : AsyncCommand<DefaultCommand.Settings>
|
|
|
|
|
{
|
2025-08-12 00:17:37 -05:00
|
|
|
private readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
|
|
|
|
{
|
|
|
|
|
WriteIndented = true,
|
|
|
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
|
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
|
|
|
};
|
2025-07-23 23:55:56 -05:00
|
|
|
private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
|
|
|
|
private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console));
|
2025-07-24 11:22:15 -05:00
|
|
|
private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor));
|
2025-07-29 18:00:09 -05:00
|
|
|
private readonly ITranscriber _transcriber = transcriber ?? throw new ArgumentNullException(nameof(transcriber));
|
2025-07-31 00:10:27 -05:00
|
|
|
private readonly ITranscriptAnalyzer _transcriptAnalyzer = transcriptAnalyzer ?? throw new ArgumentNullException(nameof(transcriptAnalyzer));
|
2025-08-12 00:17:37 -05:00
|
|
|
private readonly IShortsCreator _shortsCreator = shortsCreator ?? throw new ArgumentNullException(nameof(shortsCreator));
|
|
|
|
|
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
2025-07-23 23:55:56 -05:00
|
|
|
|
2025-08-19 16:25:15 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// Represents the settings for the default command.
|
|
|
|
|
/// </summary>
|
2025-07-23 23:55:56 -05:00
|
|
|
internal class Settings : CommandSettings
|
|
|
|
|
{
|
2025-08-19 16:25:15 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets or sets the path to the stream.
|
|
|
|
|
/// </summary>
|
2025-07-23 23:55:56 -05:00
|
|
|
[CommandArgument(0, "[Stream]")]
|
|
|
|
|
[Description("The path to the stream")]
|
|
|
|
|
public string Stream { get; init; } = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override ValidationResult Validate(CommandContext context, Settings settings)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(settings.Stream))
|
|
|
|
|
{
|
|
|
|
|
return ValidationResult.Error("Stream path must be provided.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_fileSystem.File.Exists(settings.Stream) is false)
|
|
|
|
|
{
|
|
|
|
|
return ValidationResult.Error($"The specified stream file '{settings.Stream}' does not exist.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var fileExtension = _fileSystem.Path.GetExtension(settings.Stream).ToUpperInvariant();
|
|
|
|
|
|
|
|
|
|
if (fileExtension != ".MP4")
|
|
|
|
|
{
|
|
|
|
|
return ValidationResult.Error("The specified stream file must be an .mp4 file.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return base.Validate(context, settings);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-24 11:22:15 -05:00
|
|
|
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
|
2025-07-23 23:55:56 -05:00
|
|
|
{
|
2025-07-24 11:22:15 -05:00
|
|
|
_console.MarkupLine($"[blue]Processing stream:[/] {settings.Stream}");
|
|
|
|
|
var videoStream = _fileSystem.File.OpenRead(settings.Stream);
|
|
|
|
|
|
|
|
|
|
Stream? audioStream = null;
|
|
|
|
|
|
|
|
|
|
await _console.Status()
|
|
|
|
|
.Spinner(Spinner.Known.Dots)
|
2025-08-19 12:43:34 -05:00
|
|
|
.StartAsync("Extracting audio...", async _ =>
|
2025-07-24 11:22:15 -05:00
|
|
|
{
|
2025-07-29 18:21:17 -05:00
|
|
|
audioStream = await _audioExtractor.ExtractMp3FromMp4Async(videoStream);
|
2025-07-24 11:22:15 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (audioStream is null)
|
|
|
|
|
{
|
|
|
|
|
_console.MarkupLine("[red]Failed[/] to extract audio from the stream.");
|
2025-08-12 00:17:37 -05:00
|
|
|
return (int)ExitCode.FailedToExtractAudio;
|
2025-07-24 11:22:15 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]");
|
|
|
|
|
|
2025-07-29 18:00:09 -05:00
|
|
|
List<TranscriptionSegment> transcriptionSegments = [];
|
2025-07-27 23:33:05 -05:00
|
|
|
|
2025-07-29 18:00:09 -05:00
|
|
|
await _console.Status()
|
2025-07-27 23:33:05 -05:00
|
|
|
.Spinner(Spinner.Known.Dots)
|
2025-07-29 18:00:09 -05:00
|
|
|
.StartAsync("Transcribing audio...", async ctx =>
|
2025-07-27 23:33:05 -05:00
|
|
|
{
|
2025-07-29 18:00:09 -05:00
|
|
|
await foreach (var segment in _transcriber.TranscribeAsync(audioStream))
|
|
|
|
|
{
|
|
|
|
|
transcriptionSegments.Add(segment);
|
2025-08-19 15:12:12 -05:00
|
|
|
var segmentTimeText = $@"[{segment.StartTime:hh\:mm\:ss} - {segment.EndTime:hh\:mm\:ss}]";
|
2025-07-29 18:00:09 -05:00
|
|
|
ctx.Status($"Transcribed segment {segmentTimeText.EscapeMarkup()}");
|
|
|
|
|
}
|
2025-07-27 23:33:05 -05:00
|
|
|
});
|
|
|
|
|
|
2025-07-29 18:00:09 -05:00
|
|
|
_console.MarkupLine($"[blue]Transcription completed[/] [green]successfully![/]");
|
2025-07-27 23:33:05 -05:00
|
|
|
|
2025-08-19 15:12:12 -05:00
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
|
|
|
var inputFileName = _fileSystem.Path.GetFileNameWithoutExtension(settings.Stream);
|
|
|
|
|
var outputDirectoryPath = _fileSystem.Path.Combine(
|
|
|
|
|
AppContext.BaseDirectory,
|
|
|
|
|
$"{now:yyyy_MM_dd_HH_mm_ss}_{inputFileName}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
_fileSystem.Directory.CreateDirectory(outputDirectoryPath);
|
2025-08-19 16:25:15 -05:00
|
|
|
|
2025-08-19 12:43:34 -05:00
|
|
|
await _fileSystem.File.WriteAllTextAsync(
|
2025-08-19 15:12:12 -05:00
|
|
|
_fileSystem.Path.Combine(outputDirectoryPath, "transcription.txt"),
|
2025-08-19 12:43:34 -05:00
|
|
|
string.Join(Environment.NewLine, transcriptionSegments)
|
|
|
|
|
);
|
|
|
|
|
|
2025-07-31 00:10:27 -05:00
|
|
|
TranscriptAnalysis? analysis = null;
|
|
|
|
|
|
|
|
|
|
await _console.Status()
|
|
|
|
|
.Spinner(Spinner.Known.Dots)
|
2025-08-19 12:43:34 -05:00
|
|
|
.StartAsync("Analyzing transcript...", async _ =>
|
2025-07-31 00:10:27 -05:00
|
|
|
{
|
|
|
|
|
analysis = await _transcriptAnalyzer.AnalyzeAsync(transcriptionSegments);
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-12 00:17:37 -05:00
|
|
|
if (analysis is null)
|
|
|
|
|
{
|
|
|
|
|
_console.MarkupLine("[red]Failed[/] to analyze transcript.");
|
|
|
|
|
return (int)ExitCode.FailedToAnalyzeTranscript;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_console.MarkupLine($"[blue]Transcript analysis completed[/] [green]successfully![/]");
|
|
|
|
|
|
|
|
|
|
await _fileSystem.File.WriteAllTextAsync(
|
|
|
|
|
_fileSystem.Path.Combine(outputDirectoryPath, "analysis.json"),
|
|
|
|
|
JsonSerializer.Serialize(analysis, _jsonSerializerOptions)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await _console.Status()
|
|
|
|
|
.Spinner(Spinner.Known.Dots)
|
|
|
|
|
.StartAsync("Creating shorts...", async ctx =>
|
|
|
|
|
{
|
2025-08-19 12:43:34 -05:00
|
|
|
foreach (var candidate in analysis.Candidates)
|
2025-08-12 00:17:37 -05:00
|
|
|
{
|
2025-08-19 12:43:34 -05:00
|
|
|
ctx.Status($"Creating short: {candidate.Title.EscapeMarkup()}");
|
|
|
|
|
var safeFileName = string.Concat(candidate.Title.Split(_fileSystem.Path.GetInvalidFileNameChars()));
|
|
|
|
|
var candidatePath = _fileSystem.Path.Combine(outputDirectoryPath, $"{safeFileName}.mp4");
|
|
|
|
|
await _shortsCreator.CreateShortAsync(settings.Stream, candidate, candidatePath);
|
2025-08-12 00:17:37 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_console.MarkupLine($"[blue]Shorts created[/] [green]successfully![/]");
|
|
|
|
|
|
2025-07-24 11:22:15 -05:00
|
|
|
return 0;
|
2025-07-23 23:55:56 -05:00
|
|
|
}
|
2025-08-12 00:17:37 -05:00
|
|
|
|
2025-08-19 12:43:34 -05:00
|
|
|
private enum ExitCode
|
2025-08-12 00:17:37 -05:00
|
|
|
{
|
|
|
|
|
FailedToExtractAudio,
|
|
|
|
|
FailedToAnalyzeTranscript,
|
|
|
|
|
}
|
2025-07-23 23:55:56 -05:00
|
|
|
}
|