diff --git a/OpenAIChatGPTBlazor/Components/App.razor b/OpenAIChatGPTBlazor/Components/App.razor index 6b06157..419d9d7 100644 --- a/OpenAIChatGPTBlazor/Components/App.razor +++ b/OpenAIChatGPTBlazor/Components/App.razor @@ -38,6 +38,7 @@ + diff --git a/OpenAIChatGPTBlazor/Components/GenerateVideoOptions.razor b/OpenAIChatGPTBlazor/Components/GenerateVideoOptions.razor new file mode 100644 index 0000000..ad6a3c2 --- /dev/null +++ b/OpenAIChatGPTBlazor/Components/GenerateVideoOptions.razor @@ -0,0 +1,66 @@ +@using OpenAIChatGPTBlazor.Services.Models + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +@code { + [Parameter] public int Width { get; set; } + [Parameter] public EventCallback WidthChanged { get; set; } + + [Parameter] public int Height { get; set; } + [Parameter] public EventCallback HeightChanged { get; set; } + + [Parameter] public int NSeconds { get; set; } + [Parameter] public EventCallback NSecondsChanged { get; set; } + + private async Task OnResolutionChange(ChangeEventArgs e) + { + var resolution = e.Value?.ToString(); + if (!string.IsNullOrEmpty(resolution)) + { + var parts = resolution.Split('x'); + if (parts.Length == 2 && int.TryParse(parts[0], out var width) && int.TryParse(parts[1], out var height)) + { + Width = width; + Height = height; + await WidthChanged.InvokeAsync(Width); + await HeightChanged.InvokeAsync(Height); + } + } + } + + private async Task OnNSecondsChange(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var nSeconds)) + { + NSeconds = nSeconds; + await NSecondsChanged.InvokeAsync(NSeconds); + } + } +} diff --git a/OpenAIChatGPTBlazor/Components/Layout/NavMenu.razor b/OpenAIChatGPTBlazor/Components/Layout/NavMenu.razor index aafb78d..8cd960a 100644 --- a/OpenAIChatGPTBlazor/Components/Layout/NavMenu.razor +++ b/OpenAIChatGPTBlazor/Components/Layout/NavMenu.razor @@ -23,6 +23,11 @@ Image Edit + diff --git a/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor new file mode 100644 index 0000000..bac1b18 --- /dev/null +++ b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor @@ -0,0 +1,106 @@ +@page "/GenerateVideo" +@rendermode InteractiveServer +@using OpenAIChatGPTBlazor.Services +@using OpenAIChatGPTBlazor.Services.Models +@using Microsoft.FeatureManagement +@inject IJSRuntime JS +@inject IFeatureManager FeatureManager + +Video Generation + +
+
+

+ Welcome to my Video Generation using OpenAI Sora-2 +

+
+
+ + @if (_loading) + { +
+
+

... please wait ...

+ @if (!string.IsNullOrEmpty(_currentJobId)) + { +

Job ID: @_currentJobId

+

Status: @_jobStatus

+ @if (_jobProgress > 0) + { +
+
+ @_jobProgress% +
+
+ } + } + } + + @if (_warningMessage.Length > 0) + { +
+ Warning! @_warningMessage. +
+ } + + @if (_successMessage.Length > 0) + { +
+ Success! @_successMessage. +
+ } + + @if (_videoBytes != null && _videoBytes.Any()) + { +
+ @for (int i = 0; i < _videoBytes.Count; i++) + { + var base64 = System.Convert.ToBase64String(_videoBytes[i]); +
+ +
+ @if (i < _videoResults.Count) + { + var video = _videoResults[i]; + + Duration: @video.Duration.ToString("F1")s | Resolution: @video.Resolution | Format: @video.Format + + } + else + { + Video @(i + 1) + } +
+
+ } +
+ } +
+
+ +
diff --git a/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.cs b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.cs new file mode 100644 index 0000000..c8ba29d --- /dev/null +++ b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.cs @@ -0,0 +1,262 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; +using OpenAIChatGPTBlazor.Services; +using OpenAIChatGPTBlazor.Services.Models; + +namespace OpenAIChatGPTBlazor.Components.Pages; + +public partial class GenerateVideo : ComponentBase, IDisposable +{ + private CancellationTokenSource? _cancellationTokenSource; + private string _warningMessage = string.Empty; + private string _successMessage = string.Empty; + private bool _loading = false; + + private string _prompt = string.Empty; + private int _width = 720; + private int _height = 1280; + private int _nSeconds = 4; + + private readonly List _videoResults = new(); + private readonly List _videoBytes = new(); + + // Job tracking + private string _currentJobId = string.Empty; + private string _jobStatus = "Unknown"; + private int _jobProgress = 0; + + [Inject] + public required IVideoGenerationService VideoService { get; set; } + + protected override void OnInitialized() + { + _loading = false; + } + + private async Task OnSubmitClick() => await RunSubmit(); + + private async Task OnPromptKeydown(KeyboardEventArgs e) + { + if ((e.Key == "Enter" || e.Key == "NumpadEnter") && e.CtrlKey) + { + await RunSubmit(); + } + } + + private void OnAbortClick() => AbortGeneration(); + + private async Task RunSubmit() + { + if (string.IsNullOrWhiteSpace(_prompt)) + { + _warningMessage = "Please enter a prompt for video generation."; + return; + } + + try + { + _loading = true; + _warningMessage = string.Empty; + _successMessage = string.Empty; + _videoResults.Clear(); + _videoBytes.Clear(); + _currentJobId = string.Empty; + _jobStatus = "Starting..."; + _jobProgress = 0; + + StateHasChanged(); + + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + + // Create video generation request + var request = new VideoGenerationRequest + { + Prompt = _prompt, + Width = _width, + Height = _height, + NSeconds = _nSeconds, + Model = "sora-2", + }; + + // Submit the job + var jobResponse = await VideoService.CreateVideoJobAsync( + request, + _cancellationTokenSource.Token + ); + _currentJobId = jobResponse.JobId; + _jobStatus = jobResponse.Status; + + StateHasChanged(); + + // Start polling for job completion + await PollJobStatus(); + } + catch (TaskCanceledException) + when (_cancellationTokenSource?.IsCancellationRequested == true) + { + _warningMessage = "Video generation was cancelled."; + } + catch (Exception ex) + { + _warningMessage = $"Error generating video: {ex.Message}"; + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task PollJobStatus() + { + const int maxPollingAttempts = 120; // 10 minutes with 5-second intervals + var attempts = 0; + + while ( + attempts < maxPollingAttempts + && !_cancellationTokenSource!.Token.IsCancellationRequested + ) + { + try + { + var statusResponse = await VideoService.GetJobStatusAsync( + _currentJobId, + _cancellationTokenSource.Token + ); + _jobStatus = statusResponse.Status; + + // Update progress from API response or estimate + _jobProgress = statusResponse.Progress > 0 + ? statusResponse.Progress + : EstimateProgress(statusResponse.Status, attempts, maxPollingAttempts); + + StateHasChanged(); + + // Check if job completed - new API uses "completed" status + if (statusResponse.Status == "succeeded" || statusResponse.Status == "completed") + { + // New Sora-2 API: use the job ID directly as the video ID + if (statusResponse.Generations != null && statusResponse.Generations.Count > 0) + { + // Legacy format with generations array + _videoResults.AddRange(statusResponse.Generations); + _successMessage = + $"Video generation completed successfully! Generated {statusResponse.Generations.Count} video(s)."; + await RetrieveVideoContent(); + } + else + { + // New format: use job ID directly + var generation = new VideoGeneration + { + GenerationId = statusResponse.JobId, + Duration = double.TryParse(statusResponse.Seconds, out var sec) ? sec : 0, + Resolution = statusResponse.Size ?? "720x1280", + Format = "mp4" + }; + _videoResults.Add(generation); + _successMessage = "Video generation completed successfully!"; + await RetrieveVideoContent(); + } + return; + } + else if (statusResponse.Status == "failed") + { + _warningMessage = statusResponse.Error?.Message ?? "Video generation failed."; + return; + } + else if (statusResponse.Status == "cancelled") + { + _warningMessage = "Video generation was cancelled."; + return; + } + + // Continue polling for pending/running status + await Task.Delay(5000, _cancellationTokenSource.Token); + attempts++; + } + catch (TaskCanceledException) + when (_cancellationTokenSource.Token.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _warningMessage = $"Error checking job status: {ex.Message}"; + return; + } + } + + if (attempts >= maxPollingAttempts) + { + _warningMessage = "Video generation timed out. Please try again."; + } + } + + private async Task RetrieveVideoContent() + { + try + { + // Download video content for each generation + foreach (var generation in _videoResults) + { + var videoContent = await VideoService.GetVideoContentAsync( + generation.GenerationId, + _cancellationTokenSource!.Token + ); + _videoBytes.Add(videoContent); + } + + _successMessage = + $"Video generation completed! Downloaded {_videoBytes.Count} video(s)"; + } + catch (Exception ex) + { + _warningMessage = $"Error retrieving video content: {ex.Message}"; + } + } + + private static int EstimateProgress(string status, int attempts, int maxAttempts) + { + return status switch + { + "pending" => Math.Min(10, (attempts * 5)), + "running" => Math.Min(90, 20 + (attempts * 60 / maxAttempts)), + "succeeded" => 100, + "failed" => 0, + "cancelled" => 0, + _ => Math.Min(50, attempts * 100 / maxAttempts), + }; + } + + private void AbortGeneration() + { + try + { + _cancellationTokenSource?.Cancel(); + _loading = false; + _jobStatus = "Cancelled"; + _warningMessage = "Video generation cancelled."; + } + catch (Exception ex) + { + _warningMessage = $"Error cancelling generation: {ex.Message}"; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _cancellationTokenSource?.Dispose(); + } + } +} diff --git a/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.css b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.css new file mode 100644 index 0000000..b229015 --- /dev/null +++ b/OpenAIChatGPTBlazor/Components/Pages/GenerateVideo.razor.css @@ -0,0 +1,102 @@ +.video-container { + min-height: 70vh; +} + +.generated-video { + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 1rem; +} + +.video-info { + text-align: center; + max-width: 100%; +} + +.video-options .form-group { + margin-bottom: 1rem; +} + +.video-options .form-label { + font-weight: 600; + margin-bottom: 0.5rem; + display: block; +} + +.video-options .form-select { + border-radius: 6px; + border: 1px solid #ced4da; +} + +.video-options .form-select:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.progress { + height: 20px; + border-radius: 10px; + background-color: #e9ecef; +} + +.progress-bar { + background: linear-gradient(45deg, #007bff, #0056b3); + border-radius: 10px; + transition: width 0.3s ease; +} + +.loader { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 2s linear infinite; + margin: 20px auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.alert { + border-radius: 8px; + margin-bottom: 1rem; +} + +.btn { + border-radius: 6px; + font-weight: 500; +} + +.btn-success { + background: linear-gradient(45deg, #28a745, #20c997); + border: none; +} + +.btn-danger { + background: linear-gradient(45deg, #dc3545, #e74c3c); + border: none; +} + +.btn-outline-primary { + color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:hover { + background-color: #007bff; + border-color: #007bff; +} + +#videoPromptArea { + resize: vertical; + min-height: 80px; + border-radius: 6px; +} + +.header h4 { + color: #2c3e50; + font-weight: 600; +} diff --git a/OpenAIChatGPTBlazor/Program.cs b/OpenAIChatGPTBlazor/Program.cs index 0e7e829..ec29ac6 100644 --- a/OpenAIChatGPTBlazor/Program.cs +++ b/OpenAIChatGPTBlazor/Program.cs @@ -35,8 +35,18 @@ builder.Services.AddFeatureManagement(); builder.Services.Configure>(builder.Configuration.GetSection("OpenAI")); +// Add HttpClient for video generation service +builder.Services.AddHttpClient(); + +// Register video generation service +builder.Services.AddScoped< + OpenAIChatGPTBlazor.Services.IVideoGenerationService, + OpenAIChatGPTBlazor.Services.VideoGenerationService +>(); + builder.AddKeyedAzureOpenAIClient("OpenAi"); builder.AddKeyedAzureOpenAIClient("OpenAi_Image"); +builder.AddKeyedAzureOpenAIClient("OpenAi_Video"); var app = builder.Build(); diff --git a/OpenAIChatGPTBlazor/Services/Models/VideoModels.cs b/OpenAIChatGPTBlazor/Services/Models/VideoModels.cs new file mode 100644 index 0000000..a655792 --- /dev/null +++ b/OpenAIChatGPTBlazor/Services/Models/VideoModels.cs @@ -0,0 +1,327 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenAIChatGPTBlazor.Services.Models; + +public class VideoGenerationRequest +{ + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = string.Empty; + + [JsonPropertyName("size")] + public string Size { get; set; } = "720x1280"; + + [JsonPropertyName("seconds")] + public string Seconds { get; set; } = "4"; + + [JsonPropertyName("model")] + public string Model { get; set; } = "sora-2"; + + // Helper properties for backwards compatibility (not serialized) + [JsonIgnore] + public int Width + { + get => ParseSize().width; + set => UpdateSize(value, Height); + } + + [JsonIgnore] + public int Height + { + get => ParseSize().height; + set => UpdateSize(Width, value); + } + + [JsonIgnore] + public int NSeconds + { + get => int.TryParse(Seconds, out var s) ? s : 4; + set + { + // Sora-2 only supports 4, 8, or 12 seconds + // Round to nearest supported value + if (value <= 4) + Seconds = "4"; + else if (value <= 8) + Seconds = "8"; + else + Seconds = "12"; + } + } + + private (int width, int height) ParseSize() + { + var parts = Size.Split('x'); + if ( + parts.Length == 2 + && int.TryParse(parts[0], out var w) + && int.TryParse(parts[1], out var h) + ) + { + return (w, h); + } + return (720, 1280); + } + + private void UpdateSize(int width, int height) + { + Size = $"{width}x{height}"; + } +} + +public class VideoJobResponse +{ + [JsonPropertyName("id")] + public string JobId { get; set; } = string.Empty; + + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + [JsonConverter(typeof(UnixTimestampConverter))] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("completed_at")] + [JsonConverter(typeof(NullableUnixTimestampConverter))] + public DateTime? CompletedAt { get; set; } + + [JsonPropertyName("expires_at")] + [JsonConverter(typeof(NullableUnixTimestampConverter))] + public DateTime? ExpiresAt { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("progress")] + public int Progress { get; set; } + + [JsonPropertyName("seconds")] + public string? Seconds { get; set; } + + [JsonPropertyName("size")] + public string? Size { get; set; } + + [JsonPropertyName("remixed_from_video_id")] + public string? RemixedFromVideoId { get; set; } + + [JsonPropertyName("error")] + public VideoError? Error { get; set; } +} + +public class VideoError +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +public class VideoGeneration +{ + [JsonPropertyName("id")] + public string GenerationId { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string? VideoUrl { get; set; } + + [JsonPropertyName("thumbnail_url")] + public string? ThumbnailUrl { get; set; } + + [JsonPropertyName("duration")] + public double Duration { get; set; } + + [JsonPropertyName("format")] + public string Format { get; set; } = "mp4"; + + [JsonPropertyName("resolution")] + public string Resolution { get; set; } = string.Empty; +} + +public class VideoJobStatusResponse +{ + [JsonPropertyName("id")] + public string JobId { get; set; } = string.Empty; + + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + [JsonConverter(typeof(UnixTimestampConverter))] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("completed_at")] + [JsonConverter(typeof(NullableUnixTimestampConverter))] + public DateTime? CompletedAt { get; set; } + + [JsonPropertyName("expires_at")] + [JsonConverter(typeof(NullableUnixTimestampConverter))] + public DateTime? ExpiresAt { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("progress")] + public int Progress { get; set; } + + [JsonPropertyName("seconds")] + public string? Seconds { get; set; } + + [JsonPropertyName("size")] + public string? Size { get; set; } + + [JsonPropertyName("remixed_from_video_id")] + public string? RemixedFromVideoId { get; set; } + + [JsonPropertyName("error")] + public VideoError? Error { get; set; } + + // Legacy support for old API format +[JsonPropertyName("generations")] + public List? Generations { get; set; } +} + +public enum VideoJobStatus +{ + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +public static class VideoResolutions +{ + public static readonly Dictionary Options = new() + { + { "1920x1080", "Full HD (1920x1080)" }, + { "1080x1920", "Vertical HD (1080x1920)" }, + { "1280x720", "HD (1280x720)" }, + { "720x1280", "Vertical HD (720x1280)" }, + { "1024x1024", "Square (1024x1024)" }, + }; +} + +// Unix timestamp JSON converter for DateTime +public class UnixTimestampConverter : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Number) + { + // Handle Unix timestamp (seconds since epoch) + var unixTimestamp = reader.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).DateTime; + } + else if (reader.TokenType == JsonTokenType.String) + { + // Handle ISO 8601 string format as fallback + var dateString = reader.GetString(); + if ( + DateTime.TryParse( + dateString, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsedDate + ) + ) + { + return parsedDate; + } + } + + throw new JsonException($"Unable to convert {reader.TokenType} to DateTime"); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + // Write as Unix timestamp + var unixTimestamp = ((DateTimeOffset)value).ToUnixTimeSeconds(); + writer.WriteNumberValue(unixTimestamp); + } +} + +// Nullable Unix timestamp JSON converter +public class NullableUnixTimestampConverter : JsonConverter +{ + public override DateTime? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.Number) + { + var unixTimestamp = reader.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).DateTime; + } + else if (reader.TokenType == JsonTokenType.String) + { + var dateString = reader.GetString(); + if ( + DateTime.TryParse( + dateString, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsedDate + ) + ) + { + return parsedDate; + } + } + + throw new JsonException($"Unable to convert {reader.TokenType} to DateTime?"); + } + + public override void Write( + Utf8JsonWriter writer, + DateTime? value, + JsonSerializerOptions options + ) + { + if (value.HasValue) + { + var unixTimestamp = ((DateTimeOffset)value.Value).ToUnixTimeSeconds(); + writer.WriteNumberValue(unixTimestamp); + } + else + { + writer.WriteNullValue(); + } + } +} + +public static class VideoDurations +{ + public static readonly int[] Options = { 4, 8, 12 }; +} + +public static class VideoStyles +{ + public static readonly Dictionary Options = new() + { + { "", "Default" }, + { "cinematic", "Cinematic" }, + { "photorealistic", "Photorealistic" }, + { "animated", "Animated" }, + { "artistic", "Artistic" }, + { "documentary", "Documentary" }, + }; +} diff --git a/OpenAIChatGPTBlazor/Services/VideoGenerationService.cs b/OpenAIChatGPTBlazor/Services/VideoGenerationService.cs new file mode 100644 index 0000000..da72e06 --- /dev/null +++ b/OpenAIChatGPTBlazor/Services/VideoGenerationService.cs @@ -0,0 +1,326 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.DependencyInjection; +using OpenAI; +using OpenAIChatGPTBlazor.Services.Models; + +namespace OpenAIChatGPTBlazor.Services; + +public interface IVideoGenerationService +{ + public Task CreateVideoJobAsync( + VideoGenerationRequest request, + CancellationToken cancellationToken = default + ); + public Task GetJobStatusAsync( + string jobId, + CancellationToken cancellationToken = default + ); + public Task GetVideoContentAsync( + string generationId, + CancellationToken cancellationToken = default + ); +} + +public class VideoGenerationService : IVideoGenerationService +{ + private readonly HttpClient _httpClient; + private readonly OpenAIClient _openAIClient; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly string _baseUrl; + private readonly string? _apiKey; + private readonly bool _useApiKey; + + public VideoGenerationService( + HttpClient httpClient, + [FromKeyedServices("OpenAi_Video")] OpenAIClient openAIClient, + ILogger logger, + IConfiguration configuration + ) + { + _httpClient = httpClient; + _openAIClient = openAIClient; + _logger = logger; + _configuration = configuration; + + // Parse connection string to extract endpoint and API key + var connectionString = configuration.GetConnectionString("OpenAi_Video"); + (_baseUrl, _apiKey, _useApiKey) = ParseConnectionString(connectionString); + } + + private (string baseUrl, string? apiKey, bool useApiKey) ParseConnectionString( + string? connectionString + ) + { + if (string.IsNullOrEmpty(connectionString)) + { + // Fallback to extracting from OpenAI client + return (ExtractBaseUrlFromClient(), null, false); + } + + var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries); + string? endpoint = null; + string? key = null; + + foreach (var part in parts) + { + var keyValue = part.Split('=', 2); + if (keyValue.Length == 2) + { + var paramName = keyValue[0].Trim(); + var paramValue = keyValue[1].Trim(); + + if (paramName.Equals("Endpoint", StringComparison.OrdinalIgnoreCase)) + { + endpoint = paramValue.TrimEnd('/'); + } + else if (paramName.Equals("Key", StringComparison.OrdinalIgnoreCase)) + { + key = paramValue; + } + } + } + + var baseUrl = endpoint ?? ExtractBaseUrlFromClient(); + var useApiKey = !string.IsNullOrEmpty(key); + + _logger.LogDebug( + "Video service configured with {AuthMethod} authentication", + useApiKey ? "API Key" : "Managed Identity" + ); + + return (baseUrl, key, useApiKey); + } + + private string ExtractBaseUrlFromClient() + { + // Extract the endpoint from the OpenAI client + // This assumes the client is configured with Azure OpenAI endpoint + try + { + var endpoint = _openAIClient + .GetType() + .GetProperty("Endpoint") + ?.GetValue(_openAIClient) + ?.ToString(); + + if (!string.IsNullOrEmpty(endpoint)) + { + return endpoint.TrimEnd('/'); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not extract endpoint from OpenAI client"); + } + + // Fallback to configuration or default + return "https://oai-alex-sdc.openai.azure.com"; + } + + private async Task SetAuthenticationHeaderAsync(HttpRequestMessage httpRequest) + { + if (_useApiKey && !string.IsNullOrEmpty(_apiKey)) + { + httpRequest.Headers.Add("Api-key", _apiKey); + } + else + { + try + { + // Use DefaultAzureCredential for managed identity authentication + var credential = new Azure.Identity.DefaultAzureCredential(); + var tokenRequestContext = new TokenRequestContext( + new[] { "https://cognitiveservices.azure.com/.default" } + ); + var accessToken = await credential.GetTokenAsync( + tokenRequestContext, + CancellationToken.None + ); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + accessToken.Token + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get bearer token using DefaultAzureCredential"); + throw new InvalidOperationException( + "Could not obtain bearer token for video generation", + ex + ); + } + } + } + + public async Task CreateVideoJobAsync( + VideoGenerationRequest request, + CancellationToken cancellationToken = default + ) + { + try + { + var url = $"{_baseUrl}/openai/v1/videos"; + + var jsonContent = JsonSerializer.Serialize(request); + + _logger.LogInformation( + "Sending video generation request. Prompt: {Prompt}, Size: {Size}, Seconds: {Seconds}, Model: {Model}", + request.Prompt, + request.Size, + request.Seconds, + request.Model + ); + _logger.LogDebug("Request JSON: {Json}", jsonContent); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"), + }; + + // Set authentication header based on configuration + await SetAuthenticationHeaderAsync(httpRequest); + + var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Response JSON: {Json}", responseContent); + + var jobResponse = JsonSerializer.Deserialize( + responseContent, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + _logger.LogInformation( + "Video generation job created successfully. Job ID: {JobId}", + jobResponse?.JobId + ); + return jobResponse + ?? throw new InvalidOperationException("Failed to deserialize job response"); + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Failed to create video generation job. Status: {StatusCode}, Content: {Content}", + response.StatusCode, + errorContent + ); + throw new HttpRequestException( + $"Video generation job creation failed: {response.StatusCode} - {errorContent}" + ); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating video generation job"); + throw; + } + } + + public async Task GetJobStatusAsync( + string jobId, + CancellationToken cancellationToken = default + ) + { + try + { + var url = $"{_baseUrl}/openai/v1/videos/{jobId}"; + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); + await SetAuthenticationHeaderAsync(httpRequest); + + var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Job status response JSON: {Json}", responseContent); + + var statusResponse = JsonSerializer.Deserialize( + responseContent, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + _logger.LogInformation( + "Job {JobId} status: {Status}, Progress: {Progress}%", + jobId, + statusResponse?.Status, + statusResponse?.Progress ?? 0 + ); + + return statusResponse + ?? throw new InvalidOperationException("Failed to deserialize status response"); + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Failed to get job status for {JobId}. Status: {StatusCode}, Content: {Content}", + jobId, + response.StatusCode, + errorContent + ); + throw new HttpRequestException( + $"Failed to get job status: {response.StatusCode} - {errorContent}" + ); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting job status for {JobId}", jobId); + throw; + } + } + + public async Task GetVideoContentAsync( + string generationId, + CancellationToken cancellationToken = default + ) + { + try + { + var url = $"{_baseUrl}/openai/v1/videos/{generationId}/content"; + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); + await SetAuthenticationHeaderAsync(httpRequest); + + var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var videoContent = await response.Content.ReadAsByteArrayAsync(cancellationToken); + _logger.LogInformation( + "Retrieved video content for generation {GenerationId}. Size: {Size} bytes", + generationId, + videoContent.Length + ); + return videoContent; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Failed to get video content for {GenerationId}. Status: {StatusCode}, Content: {Content}", + generationId, + response.StatusCode, + errorContent + ); + throw new HttpRequestException( + $"Failed to get video content: {response.StatusCode} - {errorContent}" + ); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting video content for {GenerationId}", generationId); + throw; + } + } +} diff --git a/OpenAIChatGPTBlazor/wwwroot/js/video-utils.js b/OpenAIChatGPTBlazor/wwwroot/js/video-utils.js new file mode 100644 index 0000000..15dc985 --- /dev/null +++ b/OpenAIChatGPTBlazor/wwwroot/js/video-utils.js @@ -0,0 +1,47 @@ +// Video generation utility functions + +window.downloadFile = (url, filename) => { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +// Video player utilities +window.videoUtils = { + // Set video playback speed + setPlaybackSpeed: (videoElement, speed) => { + if (videoElement) { + videoElement.playbackRate = speed; + } + }, + + // Get video metadata + getVideoMetadata: (videoElement) => { + if (videoElement) { + return { + duration: videoElement.duration, + currentTime: videoElement.currentTime, + width: videoElement.videoWidth, + height: videoElement.videoHeight, + paused: videoElement.paused + }; + } + return null; + }, + + // Capture video frame as image + captureFrame: (videoElement) => { + if (videoElement) { + const canvas = document.createElement('canvas'); + canvas.width = videoElement.videoWidth; + canvas.height = videoElement.videoHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/png'); + } + return null; + } +};