视频保存

This commit is contained in:
duoaohui
2026-05-19 15:08:19 +08:00
parent 624b38140d
commit 8e5770eb5d

View File

@@ -1,10 +1,12 @@
package service package service
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -254,14 +256,19 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
outputPath = rec.FailureReason outputPath = rec.FailureReason
} }
// 上传到 MinIO // 方案 2媒体管道仍出 .webmVP8/Opusgo-service 同步用 ffmpeg 转码到 MP4(H264/AAC)
// 后缀 .webm 与 media-server 实际产物保持一致 // 转码成功 → 上传 MP4浏览器/STT 通用兼容、体积更小
// VP8/Opus 不能装进 mp4media-server 直接出 webm后续如需 mp4 可异步转码 // 转码失败 → 回退上传原始 WebM保证用户拿得到回放不让转码引入回放黑洞
objectKey := fmt.Sprintf("recordings/%s/%d-%s.webm", strings.ToLower(code), rec.ID, rec.RemoteID) uploadPath, uploadExt, uploadContentType, transcodedPath := s.prepareUploadArtifact(ctx, outputPath, rec.ID)
fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, outputPath, objectKey) objectKey := fmt.Sprintf("recordings/%s/%d-%s%s", strings.ToLower(code), rec.ID, rec.RemoteID, uploadExt)
fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, uploadPath, objectKey, uploadContentType)
if uploadErr != nil { if uploadErr != nil {
logs.Error(ctx, funcName, "录制文件上传 MinIO 失败", logs.Error(ctx, funcName, "录制文件上传 MinIO 失败",
zap.Int64("recording_id", rec.ID), zap.String("local_path", outputPath), zap.Error(uploadErr)) zap.Int64("recording_id", rec.ID), zap.String("local_path", uploadPath), zap.Error(uploadErr))
// 转码产物上传失败时残留 mp4 也清掉,避免下次轮询误判
if transcodedPath != "" {
_ = os.Remove(transcodedPath)
}
s.markFailedAndBroadcast(ctx, room.ID, rec, "录制文件上传失败") s.markFailedAndBroadcast(ctx, room.ID, rec, "录制文件上传失败")
return nil, uploadErr return nil, uploadErr
} }
@@ -284,10 +291,13 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
} }
// 上传成功后再尝试删除本地文件(失败仅 Warn不影响业务 // 上传成功后再尝试删除本地文件(失败仅 Warn不影响业务
if outputPath != "" { for _, p := range []string{outputPath, transcodedPath} {
if err := os.Remove(outputPath); err != nil && !errors.Is(err, os.ErrNotExist) { if p == "" {
logs.Warn(ctx, funcName, "删除 media-server 本地录制文件失败(忽略)", continue
zap.String("local_path", outputPath), zap.Error(err)) }
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
logs.Warn(ctx, funcName, "删除本地录制文件失败(忽略)",
zap.String("local_path", p), zap.Error(err))
} }
} }
@@ -446,8 +456,9 @@ func (s *MeetingRecordingService) listHostProducerIDs(ctx context.Context, roomC
return out, nil return out, nil
} }
// uploadRecording 把本地 mp4 上传到 MinIO返回可访问 URL 与文件大小 // uploadRecording 把本地录制文件上传到 MinIO返回可访问 URL 与文件大小
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey string) (string, int64, error) { // contentType 由调用方根据实际产物mp4 / webm传入避免错误的 Content-Type 让浏览器拒播
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey, contentType string) (string, int64, error) {
if localPath == "" { if localPath == "" {
return "", 0, fmt.Errorf("本地录制文件路径为空") return "", 0, fmt.Errorf("本地录制文件路径为空")
} }
@@ -465,8 +476,11 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
} }
defer f.Close() defer f.Close()
if contentType == "" {
contentType = "application/octet-stream"
}
if _, err := s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectKey, f, stat.Size(), minio.PutObjectOptions{ if _, err := s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectKey, f, stat.Size(), minio.PutObjectOptions{
ContentType: "video/mp4", ContentType: contentType,
}); err != nil { }); err != nil {
return "", 0, fmt.Errorf("PutObject 失败: %w", err) return "", 0, fmt.Errorf("PutObject 失败: %w", err)
} }
@@ -479,6 +493,88 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
return url, stat.Size(), nil return url, stat.Size(), nil
} }
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
//
// 方案 2 同步转码:
// - 输入是 media-server 写出的 .webmVP8/Opus
// - 优先 ffmpeg 同步转 .mp4H264/AAC + faststart用 MP4 上传,体积更小、回放兼容性更好
// - 转码失败ffmpeg 不存在 / 退出非零 / 产物为空)→ 回退原 .webm保证回放可用
//
// 返回:
// - uploadPath 实际要上传的本地路径
// - uploadExt 对应对象 Key 后缀(".mp4" 或 ".webm"),与文件容器一致
// - contentType MinIO Content-Type
// - transcodedPath 转码产物的本地路径(成功才非空),用于上传后清理
func (s *MeetingRecordingService) prepareUploadArtifact(ctx context.Context, webmPath string, recordingID int64) (uploadPath, uploadExt, contentType, transcodedPath string) {
funcName := "service.meeting_recording_service.prepareUploadArtifact"
if webmPath == "" {
return webmPath, ".webm", "video/webm", ""
}
mp4Path, err := s.transcodeWebmToMp4(ctx, webmPath)
if err != nil {
logs.Warn(ctx, funcName, "WebM→MP4 转码失败,回退上传原始 webm",
zap.Int64("recording_id", recordingID), zap.String("webm_path", webmPath), zap.Error(err))
return webmPath, ".webm", "video/webm", ""
}
logs.Info(ctx, funcName, "WebM→MP4 转码完成",
zap.Int64("recording_id", recordingID), zap.String("mp4_path", mp4Path))
return mp4Path, ".mp4", "video/mp4", mp4Path
}
// transcodeWebmToMp4 同步调用 ffmpeg 把本地 webm 转成 mp4(H264/AAC + faststart)
//
// 失败条件(均返回 error调用方回退原 webm 上传):
// - 系统找不到 ffmpeg
// - ffmpeg 退出码非零stderr 尾部写入 error 字段便于排障)
// - 转码产物不存在或大小为 0
//
// 参数选型:
// - -c:v libx264 -preset veryfast -crf 23常见 1080p 短会议清晰度/CPU 折中
// - -c:a aac -b:a 128k覆盖绝大多数浏览器原生播放
// - -movflags +faststartmoov 前置HTTP Range 即点即播
// - -y覆盖目标文件避免重复 stop 时 EEXIST
func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPath string) (string, error) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return "", fmt.Errorf("ffmpeg 未安装或不在 PATH: %w", err)
}
mp4Path := strings.TrimSuffix(webmPath, filepath.Ext(webmPath)) + ".mp4"
// 转码上限:单文件最长容忍 10 分钟 CPU 时间,超时则视为失败回退
tctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(tctx, "ffmpeg",
"-y",
"-i", webmPath,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
mp4Path,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// 失败时清理半成品,避免后续误用
_ = os.Remove(mp4Path)
tail := stderr.String()
if len(tail) > 2048 {
tail = tail[len(tail)-2048:]
}
return "", fmt.Errorf("ffmpeg 转码失败: %w | stderr_tail=%s", err, tail)
}
stat, err := os.Stat(mp4Path)
if err != nil {
return "", fmt.Errorf("转码产物不存在: %w", err)
}
if stat.Size() == 0 {
_ = os.Remove(mp4Path)
return "", fmt.Errorf("转码产物为 0 字节")
}
return mp4Path, nil
}
// GetByRemoteID 反查本地录制记录(按 media-server 的 remoteId 字符串) // GetByRemoteID 反查本地录制记录(按 media-server 的 remoteId 字符串)
// webhook sink 入口前置查询,定位 Go 端主键 // webhook sink 入口前置查询,定位 Go 端主键
func (s *MeetingRecordingService) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) { func (s *MeetingRecordingService) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) {