v.0.0.3 Изменена логика обрезки видео и его сохранения в s3
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
admin 2025-09-02 23:17:24 +03:00
parent 7bfc7eb9a8
commit 272c76909b
2 changed files with 122 additions and 71 deletions

View File

@ -3,6 +3,7 @@ package ffmpeg
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
) )
@ -12,15 +13,46 @@ type VideoProcessor interface {
} }
type videoProcessor struct { type videoProcessor struct {
tempDir string // опционально: каталог для временных файлов
} }
func NewVideoProcessor() VideoProcessor { 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) { func (vp *videoProcessor) GenerateThumbnail(videoData []byte) ([]byte, error) {
var thumbnailData []byte
err := vp.withTempFile("thumbnail_*.mp4", videoData, func(inputPath string) error {
cmd := exec.Command("ffmpeg", cmd := exec.Command("ffmpeg",
"-i", "pipe:0", "-i", inputPath,
"-ss", "00:00:01", "-ss", "00:00:01",
"-vframes", "1", "-vframes", "1",
"-q:v", "2", "-q:v", "2",
@ -29,48 +61,70 @@ func (vp *videoProcessor) GenerateThumbnail(videoData []byte) ([]byte, error) {
"pipe:1", "pipe:1",
) )
cmd.Stdin = bytes.NewReader(videoData)
var output bytes.Buffer var output bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &output cmd.Stdout = &output
cmd.Stderr = &stderr cmd.Stderr = &stderr
err := cmd.Run() if err := cmd.Run(); err != nil {
if err != nil { return fmt.Errorf("ffmpeg failed: %s, error: %w", stderr.String(), err)
return nil, fmt.Errorf("ffmpeg failed: %s, error: %w", stderr.String(), err)
} }
if output.Len() == 0 { thumbnailData = output.Bytes()
return nil, fmt.Errorf("thumbnail generation produced empty output") return nil
} })
return output.Bytes(), nil return thumbnailData, err
} }
func (vp *videoProcessor) TrimVideo(videoData []byte, maxDuration int) ([]byte, error) { 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", cmd := exec.Command("ffmpeg",
"-i", "pipe:0", "-i", inputFile.Name(),
"-t", fmt.Sprintf("%d", maxDuration), "-t", fmt.Sprintf("%d", maxDuration),
"-c", "copy", "-c", "copy",
"-f", "mp4", "-y", // overwrite output file
"pipe:1", outputFile.Name(),
) )
cmd.Stdin = bytes.NewReader(videoData)
var output bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &stderr cmd.Stderr = &stderr
err := cmd.Run() if err := cmd.Run(); err != nil {
if err != nil {
return nil, fmt.Errorf("ffmpeg trim failed: %s, error: %w", stderr.String(), err) 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 nil, fmt.Errorf("trimmed video is empty")
} }
return output.Bytes(), nil return trimmedData, nil
} }

View File

@ -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) { func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error) {
const op = "service/clipService.CreateClip" const op = "service/clipService.CreateClip"
// 1. Обрезаем видео до 30 секунд в памяти // 1. Параллельно обрабатываем видео и генерируем превью
trimmedVideoData, err := s.videoProcessor.TrimVideo(req.VideoData, 30) trimmedVideoChan := make(chan []byte, 1)
if err != nil { trimmedErrChan := make(chan error, 1)
return nil, fmt.Errorf("%s: failed to trim video: %w", op, err) thumbnailChan := make(chan []byte, 1)
}
// 2. Параллельно выполняем операции
videoURLChan := make(chan string, 1)
videoErrChan := make(chan error, 1)
thumbnailChan := make(chan string, 1)
thumbnailErrChan := make(chan error, 1) thumbnailErrChan := make(chan error, 1)
// Горутина для загрузки видео в S3 // Горутина для обрезки видео
go func() { go func() {
videoURL, err := s.storage.UploadVideo(ctx, trimmedVideoData, req.FileName, req.UserID) trimmedVideo, err := s.videoProcessor.TrimVideo(req.VideoData, 30)
if err != nil { 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 return
} }
videoURLChan <- videoURL trimmedVideoChan <- trimmedVideo
}() }()
// Горутина для генерации превью // Горутина для генерации превью
go func() { go func() {
thumbnailData, err := s.videoProcessor.GenerateThumbnail(trimmedVideoData) thumbnailData, err := s.videoProcessor.GenerateThumbnail(req.VideoData)
if err != nil { if err != nil {
thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err) thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err)
return return
} }
thumbnailChan <- thumbnailData
}()
// 2. Ждем результаты обработки
var trimmedVideo []byte
var thumbnailData []byte
select {
case trimmedVideo = <-trimmedVideoChan:
case err := <-trimmedErrChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("%s: operation cancelled", op)
}
select {
case thumbnailData = <-thumbnailChan:
case err := <-thumbnailErrChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("%s: operation cancelled", op)
}
// 3. Загружаем обрезанное видео и превью в S3
trimmedVideoURL, err := s.storage.UploadVideo(ctx, trimmedVideo, req.FileName, req.UserID)
if err != nil {
return nil, fmt.Errorf("%s: failed to upload trimmed video: %w", op, err)
}
thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID) thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID)
if err != nil { if err != nil {
thumbnailErrChan <- fmt.Errorf("%s: failed to upload thumbnail: %w", op, err) s.storage.DeleteVideo(ctx, trimmedVideoURL)
return return nil, fmt.Errorf("%s: failed to upload thumbnail: %w", op, err)
}
thumbnailChan <- thumbnailURL
}()
// 3. Ждем результаты операций
var videoURL, thumbnailURL string
// Ждем загрузки видео
select {
case videoURL = <-videoURLChan:
case err := <-videoErrChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("%s: operation cancelled", op)
} }
// Ждем генерации превью // 4. Модерируем обрезанное видео
select { videoAllowed, err := s.modClient.CheckVideoUrl(ctx, trimmedVideoURL)
case thumbnailURL = <-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)
if err != nil { if err != nil {
s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteVideo(ctx, trimmedVideoURL)
s.storage.DeleteThumbnail(ctx, thumbnailURL) s.storage.DeleteThumbnail(ctx, thumbnailURL)
return nil, fmt.Errorf("%s: video moderation failed: %w", op, err) return nil, fmt.Errorf("%s: video moderation failed: %w", op, err)
} }
if !videoAllowed { if !videoAllowed {
s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteVideo(ctx, trimmedVideoURL)
s.storage.DeleteThumbnail(ctx, thumbnailURL) s.storage.DeleteThumbnail(ctx, thumbnailURL)
return nil, fmt.Errorf("%s: video rejected by moderation service", op) 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. Создаем клип в БД // 5. Создаем клип в БД
clip := &domain.Clip{ clip := &domain.Clip{
Title: req.Title, Title: req.Title,
VideoURL: videoURL, VideoURL: trimmedVideoURL,
ThumbnailURL: thumbnailURL, ThumbnailURL: thumbnailURL,
AuthorID: req.UserID, AuthorID: req.UserID,
LikesCount: 0, 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 { if err := s.repo.Create(ctx, clip); err != nil {
s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteVideo(ctx, trimmedVideoURL)
s.storage.DeleteThumbnail(ctx, thumbnailURL) s.storage.DeleteThumbnail(ctx, thumbnailURL)
return nil, fmt.Errorf("failed to create clip: %w", err) return nil, fmt.Errorf("failed to create clip: %w", err)
} }