视频保存
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/logs"
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"github.com/echochat/backend/pkg/storage"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -259,12 +260,5 @@ func (s *FileService) UploadVoice(ctx context.Context, fileHeader *multipart.Fil
|
|||||||
|
|
||||||
// buildURL 根据 MinIO 配置构建文件访问 URL
|
// buildURL 根据 MinIO 配置构建文件访问 URL
|
||||||
func (s *FileService) buildURL(objectName string) string {
|
func (s *FileService) buildURL(objectName string) string {
|
||||||
if base := strings.TrimRight(strings.TrimSpace(s.minioCfg.PublicBaseURL), "/"); base != "" {
|
return storage.BuildObjectURL(s.minioCfg, objectName)
|
||||||
return fmt.Sprintf("%s/%s", base, objectName)
|
|
||||||
}
|
|
||||||
scheme := "http"
|
|
||||||
if s.minioCfg.UseSSL {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectName)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/echochat/backend/app/meeting/model"
|
"github.com/echochat/backend/app/meeting/model"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/logs"
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"github.com/echochat/backend/pkg/storage"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -503,14 +504,7 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MeetingRecordingService) buildRecordingURL(objectKey string) string {
|
func (s *MeetingRecordingService) buildRecordingURL(objectKey string) string {
|
||||||
if base := strings.TrimRight(strings.TrimSpace(s.minioCfg.PublicBaseURL), "/"); base != "" {
|
return storage.BuildObjectURL(s.minioCfg, objectKey)
|
||||||
return fmt.Sprintf("%s/%s", base, objectKey)
|
|
||||||
}
|
|
||||||
scheme := "http"
|
|
||||||
if s.minioCfg.UseSSL {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
|
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
|
||||||
@@ -542,8 +536,30 @@ func (s *MeetingRecordingService) prepareUploadArtifact(ctx context.Context, web
|
|||||||
zap.Int64("recording_id", recordingID), zap.String("webm_path", webmPath), zap.Error(err))
|
zap.Int64("recording_id", recordingID), zap.String("webm_path", webmPath), zap.Error(err))
|
||||||
return webmPath, ".webm", "video/webm", ""
|
return webmPath, ".webm", "video/webm", ""
|
||||||
}
|
}
|
||||||
|
webmStat, webmStatErr := os.Stat(webmPath)
|
||||||
|
mp4Stat, mp4StatErr := os.Stat(mp4Path)
|
||||||
|
if webmStatErr == nil && mp4StatErr == nil {
|
||||||
|
if mp4Stat.Size() >= webmStat.Size() {
|
||||||
|
logs.Warn(ctx, funcName, "MP4 转码产物未变小,回退上传原始 webm",
|
||||||
|
zap.Int64("recording_id", recordingID),
|
||||||
|
zap.String("webm_path", webmPath),
|
||||||
|
zap.Int64("webm_size_bytes", webmStat.Size()),
|
||||||
|
zap.String("mp4_path", mp4Path),
|
||||||
|
zap.Int64("mp4_size_bytes", mp4Stat.Size()))
|
||||||
|
return webmPath, ".webm", "video/webm", mp4Path
|
||||||
|
}
|
||||||
logs.Info(ctx, funcName, "WebM→MP4 转码完成",
|
logs.Info(ctx, funcName, "WebM→MP4 转码完成",
|
||||||
zap.Int64("recording_id", recordingID), zap.String("mp4_path", mp4Path))
|
zap.Int64("recording_id", recordingID),
|
||||||
|
zap.String("mp4_path", mp4Path),
|
||||||
|
zap.Int64("webm_size_bytes", webmStat.Size()),
|
||||||
|
zap.Int64("mp4_size_bytes", mp4Stat.Size()))
|
||||||
|
return mp4Path, ".mp4", "video/mp4", mp4Path
|
||||||
|
}
|
||||||
|
logs.Info(ctx, funcName, "WebM→MP4 转码完成",
|
||||||
|
zap.Int64("recording_id", recordingID),
|
||||||
|
zap.String("mp4_path", mp4Path),
|
||||||
|
zap.NamedError("webm_stat_error", webmStatErr),
|
||||||
|
zap.NamedError("mp4_stat_error", mp4StatErr))
|
||||||
return mp4Path, ".mp4", "video/mp4", mp4Path
|
return mp4Path, ".mp4", "video/mp4", mp4Path
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,8 +571,8 @@ func (s *MeetingRecordingService) prepareUploadArtifact(ctx context.Context, web
|
|||||||
// - 转码产物不存在或大小为 0
|
// - 转码产物不存在或大小为 0
|
||||||
//
|
//
|
||||||
// 参数选型:
|
// 参数选型:
|
||||||
// - -c:v libx264 -preset veryfast -crf 23:常见 1080p 短会议清晰度/CPU 折中
|
// - -c:v libx264 -preset medium -crf 30:压缩优先,避免 WebM 转 MP4 后体积变大
|
||||||
// - -c:a aac -b:a 128k:覆盖绝大多数浏览器原生播放
|
// - -c:a aac -b:a 64k:会议语音场景的体积/清晰度折中
|
||||||
// - -movflags +faststart:moov 前置,HTTP Range 即点即播
|
// - -movflags +faststart:moov 前置,HTTP Range 即点即播
|
||||||
// - -y:覆盖目标文件,避免重复 stop 时 EEXIST
|
// - -y:覆盖目标文件,避免重复 stop 时 EEXIST
|
||||||
func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPath string) (string, error) {
|
func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPath string) (string, error) {
|
||||||
@@ -587,8 +603,8 @@ func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPa
|
|||||||
cmd := exec.CommandContext(tctx, ffmpegPath,
|
cmd := exec.CommandContext(tctx, ffmpegPath,
|
||||||
"-y",
|
"-y",
|
||||||
"-i", webmPath,
|
"-i", webmPath,
|
||||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
"-c:v", "libx264", "-preset", "medium", "-crf", "30",
|
||||||
"-c:a", "aac", "-b:a", "128k",
|
"-c:a", "aac", "-b:a", "64k",
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
mp4Path,
|
mp4Path,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ log:
|
|||||||
minio:
|
minio:
|
||||||
endpoint: "minio:9000"
|
endpoint: "minio:9000"
|
||||||
access_key: "echochat"
|
access_key: "echochat"
|
||||||
secret_key: "echochat123456"
|
secret_key: "echochat_2026-1s32dswW@#"
|
||||||
bucket: "echochat"
|
bucket: "echochat"
|
||||||
use_ssl: false
|
use_ssl: false
|
||||||
public_base_url: "http://yl.z64.cn/echochat"
|
public_base_url: "http://yl.z64.cn/echochat"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
@@ -61,3 +62,28 @@ func ensureBucketPublicRead(ctx context.Context, client *minio.Client, bucket st
|
|||||||
}`, bucket)
|
}`, bucket)
|
||||||
return client.SetBucketPolicy(ctx, bucket, policy)
|
return client.SetBucketPolicy(ctx, bucket, policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BuildObjectURL(cfg *config.MinioConfig, objectName string) string {
|
||||||
|
objectName = strings.TrimLeft(strings.TrimSpace(objectName), "/")
|
||||||
|
if cfg == nil {
|
||||||
|
return objectName
|
||||||
|
}
|
||||||
|
bucket := strings.Trim(strings.TrimSpace(cfg.Bucket), "/")
|
||||||
|
if base := strings.TrimRight(strings.TrimSpace(cfg.PublicBaseURL), "/"); base != "" {
|
||||||
|
if bucket != "" && !strings.HasSuffix(base, "/"+bucket) {
|
||||||
|
base = fmt.Sprintf("%s/%s", base, bucket)
|
||||||
|
}
|
||||||
|
if objectName == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s", base, objectName)
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if cfg.UseSSL {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if objectName == "" {
|
||||||
|
return fmt.Sprintf("%s://%s/%s", scheme, cfg.Endpoint, bucket)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s/%s/%s", scheme, cfg.Endpoint, bucket, objectName)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user