diff --git a/internal/ffmpeg/processor.go b/internal/ffmpeg/processor.go index 72c883e..ea7cbc6 100644 --- a/internal/ffmpeg/processor.go +++ b/internal/ffmpeg/processor.go @@ -3,6 +3,7 @@ package ffmpeg import ( "bytes" "fmt" + "os" "os/exec" ) @@ -12,65 +13,118 @@ type VideoProcessor interface { } type videoProcessor struct { + tempDir string // опционально: каталог для временных файлов } func NewVideoProcessor() VideoProcessor { - return &videoProcessor{} + return &videoProcessor{ + tempDir: os.TempDir(), // используем системный temp каталог + } +} + +func (vp *videoProcessor) withTempFile(pattern string, data []byte, fn func(filename string) error) error { + // Создаем временный файл + file, err := os.CreateTemp(vp.tempDir, pattern) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := file.Name() + + // Гарантируем удаление файла + defer func() { + if _, err := os.Stat(tempPath); err == nil { + os.Remove(tempPath) + } + }() + + // Записываем данные + if _, err := file.Write(data); err != nil { + return fmt.Errorf("failed to write to temp file: %w", err) + } + file.Close() + + // Вызываем функцию обработки + return fn(tempPath) } func (vp *videoProcessor) GenerateThumbnail(videoData []byte) ([]byte, error) { - cmd := exec.Command("ffmpeg", - "-i", "pipe:0", - "-ss", "00:00:01", - "-vframes", "1", - "-q:v", "2", - "-f", "image2pipe", - "-c:v", "mjpeg", - "pipe:1", - ) + var thumbnailData []byte - cmd.Stdin = bytes.NewReader(videoData) - var output bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &stderr + err := vp.withTempFile("thumbnail_*.mp4", videoData, func(inputPath string) error { + cmd := exec.Command("ffmpeg", + "-i", inputPath, + "-ss", "00:00:01", + "-vframes", "1", + "-q:v", "2", + "-f", "image2pipe", + "-c:v", "mjpeg", + "pipe:1", + ) - err := cmd.Run() - if err != nil { - return nil, fmt.Errorf("ffmpeg failed: %s, error: %w", stderr.String(), err) - } + var output bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &stderr - if output.Len() == 0 { - return nil, fmt.Errorf("thumbnail generation produced empty output") - } + if err := cmd.Run(); err != nil { + return fmt.Errorf("ffmpeg failed: %s, error: %w", stderr.String(), err) + } - return output.Bytes(), nil + thumbnailData = output.Bytes() + return nil + }) + + return thumbnailData, err } func (vp *videoProcessor) TrimVideo(videoData []byte, maxDuration int) ([]byte, error) { - // Просто обрезаем видео без проверки длительности + // 1. Создаем временный файл для входного видео + inputFile, err := os.CreateTemp(vp.tempDir, "trim_input_*.mp4") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(inputFile.Name()) + defer inputFile.Close() + + // 2. Записываем видео данные во временный файл + if _, err := inputFile.Write(videoData); err != nil { + return nil, fmt.Errorf("failed to write to temp file: %w", err) + } + inputFile.Close() + + // 3. Создаем временный файл для выходного видео + outputFile, err := os.CreateTemp(vp.tempDir, "trim_output_*.mp4") + if err != nil { + return nil, fmt.Errorf("failed to create output temp file: %w", err) + } + defer os.Remove(outputFile.Name()) + defer outputFile.Close() + + // 4. Выполняем обрезку cmd := exec.Command("ffmpeg", - "-i", "pipe:0", + "-i", inputFile.Name(), "-t", fmt.Sprintf("%d", maxDuration), "-c", "copy", - "-f", "mp4", - "pipe:1", + "-y", // overwrite output file + outputFile.Name(), ) - cmd.Stdin = bytes.NewReader(videoData) - var output bytes.Buffer var stderr bytes.Buffer - cmd.Stdout = &output cmd.Stderr = &stderr - err := cmd.Run() - if err != nil { + if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg trim failed: %s, error: %w", stderr.String(), err) } - if output.Len() == 0 { + // 5. Читаем обрезанное видео + trimmedData, err := os.ReadFile(outputFile.Name()) + if err != nil { + return nil, fmt.Errorf("failed to read trimmed video: %w", err) + } + + if len(trimmedData) == 0 { return nil, fmt.Errorf("trimmed video is empty") } - return output.Bytes(), nil + return trimmedData, nil } diff --git a/internal/service/clip_service.go b/internal/service/clip_service.go index da9d097..54aa208 100644 --- a/internal/service/clip_service.go +++ b/internal/service/clip_service.go @@ -43,76 +43,73 @@ func NewClipService(repo repository.ClipRepository, storage storage.Storage, pro func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error) { const op = "service/clipService.CreateClip" - // 1. Обрезаем видео до 30 секунд в памяти - trimmedVideoData, err := s.videoProcessor.TrimVideo(req.VideoData, 30) - if err != nil { - return nil, fmt.Errorf("%s: failed to trim video: %w", op, err) - } - - // 2. Параллельно выполняем операции - videoURLChan := make(chan string, 1) - videoErrChan := make(chan error, 1) - thumbnailChan := make(chan string, 1) + // 1. Параллельно обрабатываем видео и генерируем превью + trimmedVideoChan := make(chan []byte, 1) + trimmedErrChan := make(chan error, 1) + thumbnailChan := make(chan []byte, 1) thumbnailErrChan := make(chan error, 1) - // Горутина для загрузки видео в S3 + // Горутина для обрезки видео go func() { - videoURL, err := s.storage.UploadVideo(ctx, trimmedVideoData, req.FileName, req.UserID) + trimmedVideo, err := s.videoProcessor.TrimVideo(req.VideoData, 30) if err != nil { - videoErrChan <- fmt.Errorf("%s: failed to upload video: %w", op, err) + trimmedErrChan <- fmt.Errorf("%s: failed to trim video: %w", op, err) return } - videoURLChan <- videoURL + trimmedVideoChan <- trimmedVideo }() // Горутина для генерации превью go func() { - thumbnailData, err := s.videoProcessor.GenerateThumbnail(trimmedVideoData) + thumbnailData, err := s.videoProcessor.GenerateThumbnail(req.VideoData) if err != nil { thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err) return } - - thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID) - if err != nil { - thumbnailErrChan <- fmt.Errorf("%s: failed to upload thumbnail: %w", op, err) - return - } - thumbnailChan <- thumbnailURL + thumbnailChan <- thumbnailData }() - // 3. Ждем результаты операций - var videoURL, thumbnailURL string + // 2. Ждем результаты обработки + var trimmedVideo []byte + var thumbnailData []byte - // Ждем загрузки видео select { - case videoURL = <-videoURLChan: - case err := <-videoErrChan: + case trimmedVideo = <-trimmedVideoChan: + case err := <-trimmedErrChan: return nil, err case <-ctx.Done(): return nil, fmt.Errorf("%s: operation cancelled", op) } - // Ждем генерации превью select { - case thumbnailURL = <-thumbnailChan: + case thumbnailData = <-thumbnailChan: case err := <-thumbnailErrChan: - s.storage.DeleteVideo(ctx, videoURL) return nil, err case <-ctx.Done(): - s.storage.DeleteVideo(ctx, videoURL) return nil, fmt.Errorf("%s: operation cancelled", op) } - // 4. Модерируем обрезанное видео по URL - videoAllowed, err := s.modClient.CheckVideoUrl(ctx, videoURL) + // 3. Загружаем обрезанное видео и превью в S3 + trimmedVideoURL, err := s.storage.UploadVideo(ctx, trimmedVideo, req.FileName, req.UserID) if err != nil { - s.storage.DeleteVideo(ctx, videoURL) + return nil, fmt.Errorf("%s: failed to upload trimmed video: %w", op, err) + } + + thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID) + if err != nil { + s.storage.DeleteVideo(ctx, trimmedVideoURL) + return nil, fmt.Errorf("%s: failed to upload thumbnail: %w", op, err) + } + + // 4. Модерируем обрезанное видео + videoAllowed, err := s.modClient.CheckVideoUrl(ctx, trimmedVideoURL) + if err != nil { + s.storage.DeleteVideo(ctx, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video moderation failed: %w", op, err) } if !videoAllowed { - s.storage.DeleteVideo(ctx, videoURL) + s.storage.DeleteVideo(ctx, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video rejected by moderation service", op) } @@ -120,7 +117,7 @@ func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipReque // 5. Создаем клип в БД clip := &domain.Clip{ Title: req.Title, - VideoURL: videoURL, + VideoURL: trimmedVideoURL, ThumbnailURL: thumbnailURL, AuthorID: req.UserID, LikesCount: 0, @@ -130,7 +127,7 @@ func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipReque } if err := s.repo.Create(ctx, clip); err != nil { - s.storage.DeleteVideo(ctx, videoURL) + s.storage.DeleteVideo(ctx, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("failed to create clip: %w", err) }