package services import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "veza-backend-api/internal/utils" "github.com/google/uuid" "go.uber.org/zap" ) // VideoQuality defines a video transcoding quality preset type VideoQuality struct { Name string // e.g. "720p", "480p", "360p" Resolution string // e.g. "1280x720" Bitrate string // e.g. "2500k" AudioRate string // e.g. "128k" } // VideoTranscodeResult holds the result of a video transcoding job type VideoTranscodeResult struct { LessonID uuid.UUID MasterPlaylistURL string DurationSeconds int } // DefaultVideoQualities returns the default multi-bitrate quality presets func DefaultVideoQualities() []VideoQuality { return []VideoQuality{ {Name: "720p", Resolution: "1280x720", Bitrate: "2500k", AudioRate: "128k"}, {Name: "480p", Resolution: "854x480", Bitrate: "1000k", AudioRate: "96k"}, {Name: "360p", Resolution: "640x360", Bitrate: "500k", AudioRate: "64k"}, } } // VideoTranscodeService handles HLS transcoding for course lesson videos type VideoTranscodeService struct { outputDir string qualities []VideoQuality logger *zap.Logger } // NewVideoTranscodeService creates a new video transcoding service func NewVideoTranscodeService(outputDir string, logger *zap.Logger) *VideoTranscodeService { if logger == nil { logger = zap.NewNop() } return &VideoTranscodeService{ outputDir: outputDir, qualities: DefaultVideoQualities(), logger: logger, } } // SetQualities configures the quality presets for transcoding func (s *VideoTranscodeService) SetQualities(qualities []VideoQuality) { s.qualities = qualities } // TranscodeVideo transcodes a video file into HLS multi-bitrate format func (s *VideoTranscodeService) TranscodeVideo(ctx context.Context, lessonID uuid.UUID, inputPath string) (*VideoTranscodeResult, error) { if inputPath == "" { return nil, fmt.Errorf("input video path is empty") } if _, err := os.Stat(inputPath); os.IsNotExist(err) { return nil, fmt.Errorf("video file does not exist: %s", inputPath) } lessonDir := filepath.Join(s.outputDir, fmt.Sprintf("lesson_%s", lessonID)) if err := os.MkdirAll(lessonDir, 0755); err != nil { return nil, fmt.Errorf("failed to create lesson directory: %w", err) } var cleanupErr error defer func() { if cleanupErr != nil { if err := os.RemoveAll(lessonDir); err != nil { s.logger.Error("Failed to cleanup lesson directory", zap.Error(err)) } } }() // Transcode each quality variant for _, quality := range s.qualities { if err := s.transcodeQuality(ctx, inputPath, lessonDir, quality); err != nil { cleanupErr = err return nil, fmt.Errorf("failed to transcode %s: %w", quality.Name, err) } s.logger.Info("Transcoded video quality", zap.String("quality", quality.Name), zap.String("lesson_id", lessonID.String()), ) } // Generate master playlist masterPath := filepath.Join(lessonDir, "master.m3u8") if err := s.generateMasterPlaylist(lessonDir); err != nil { cleanupErr = err return nil, fmt.Errorf("failed to generate master playlist: %w", err) } // Get video duration via ffprobe duration := s.probeDuration(ctx, inputPath) return &VideoTranscodeResult{ LessonID: lessonID, MasterPlaylistURL: masterPath, DurationSeconds: duration, }, nil } // transcodeQuality transcodes the video for a specific quality preset func (s *VideoTranscodeService) transcodeQuality(ctx context.Context, inputPath, outputDir string, quality VideoQuality) error { qualityDir := filepath.Join(outputDir, quality.Name) if err := os.MkdirAll(qualityDir, 0755); err != nil { return fmt.Errorf("failed to create quality directory: %w", err) } segmentPattern := filepath.Join(qualityDir, "segment_%03d.ts") playlistPath := filepath.Join(qualityDir, "playlist.m3u8") // Validate paths for exec.Command if !utils.ValidateExecPath(inputPath) || !utils.ValidateExecPath(playlistPath) { return fmt.Errorf("invalid file path") } cmd := exec.CommandContext(ctx, "ffmpeg", "-i", inputPath, "-vf", fmt.Sprintf("scale=%s", quality.Resolution), "-c:v", "libx264", "-preset", "medium", "-b:v", quality.Bitrate, "-c:a", "aac", "-b:a", quality.AudioRate, "-hls_time", "10", "-hls_playlist_type", "vod", "-hls_segment_filename", segmentPattern, "-hls_list_size", "0", "-y", playlistPath, ) output, err := cmd.CombinedOutput() if err != nil { s.logger.Error("FFmpeg video transcoding failed", zap.String("quality", quality.Name), zap.String("output", string(output)), zap.Error(err), ) return fmt.Errorf("ffmpeg failed: %w", err) } if _, err := os.Stat(playlistPath); os.IsNotExist(err) { return fmt.Errorf("playlist file was not created: %s", playlistPath) } return nil } // generateMasterPlaylist creates the master HLS playlist referencing all quality variants func (s *VideoTranscodeService) generateMasterPlaylist(lessonDir string) error { masterPath := filepath.Join(lessonDir, "master.m3u8") bandwidthMap := map[string]int{ "720p": 2628000, "480p": 1128000, "360p": 628000, } resolutionMap := map[string]string{ "720p": "1280x720", "480p": "854x480", "360p": "640x360", } var lines []string lines = append(lines, "#EXTM3U") lines = append(lines, "#EXT-X-VERSION:3") for _, quality := range s.qualities { bandwidth := bandwidthMap[quality.Name] if bandwidth == 0 { bandwidth = 1000000 } resolution := resolutionMap[quality.Name] playlistPath := filepath.Join(quality.Name, "playlist.m3u8") lines = append(lines, fmt.Sprintf( "#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%s,CODECS=\"avc1.64001f,mp4a.40.2\"", bandwidth, resolution, )) lines = append(lines, playlistPath) } content := strings.Join(lines, "\n") + "\n" if err := os.WriteFile(masterPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to write master playlist: %w", err) } return nil } // probeDuration uses ffprobe to get video duration in seconds func (s *VideoTranscodeService) probeDuration(ctx context.Context, inputPath string) int { if !utils.ValidateExecPath(inputPath) { return 0 } cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", inputPath, ) output, err := cmd.Output() if err != nil { s.logger.Warn("ffprobe failed to get duration", zap.Error(err)) return 0 } var duration float64 if _, err := fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration); err != nil { return 0 } return int(duration) } // CleanupLessonDir removes the transcoded files for a lesson func (s *VideoTranscodeService) CleanupLessonDir(lessonID uuid.UUID) error { lessonDir := filepath.Join(s.outputDir, fmt.Sprintf("lesson_%s", lessonID)) return os.RemoveAll(lessonDir) }