视频保存
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -254,14 +256,19 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
|
||||
outputPath = rec.FailureReason
|
||||
}
|
||||
|
||||
// 上传到 MinIO
|
||||
// 后缀 .webm 与 media-server 实际产物保持一致
|
||||
// (VP8/Opus 不能装进 mp4,media-server 直接出 webm;后续如需 mp4 可异步转码)
|
||||
objectKey := fmt.Sprintf("recordings/%s/%d-%s.webm", strings.ToLower(code), rec.ID, rec.RemoteID)
|
||||
fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, outputPath, objectKey)
|
||||
// 方案 2:媒体管道仍出 .webm(VP8/Opus),go-service 同步用 ffmpeg 转码到 MP4(H264/AAC)
|
||||
// 转码成功 → 上传 MP4,浏览器/STT 通用兼容、体积更小
|
||||
// 转码失败 → 回退上传原始 WebM,保证用户拿得到回放(不让转码引入回放黑洞)
|
||||
uploadPath, uploadExt, uploadContentType, transcodedPath := s.prepareUploadArtifact(ctx, outputPath, rec.ID)
|
||||
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 {
|
||||
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, "录制文件上传失败")
|
||||
return nil, uploadErr
|
||||
}
|
||||
@@ -284,10 +291,13 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
|
||||
}
|
||||
|
||||
// 上传成功后再尝试删除本地文件(失败仅 Warn,不影响业务)
|
||||
if outputPath != "" {
|
||||
if err := os.Remove(outputPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
logs.Warn(ctx, funcName, "删除 media-server 本地录制文件失败(忽略)",
|
||||
zap.String("local_path", outputPath), zap.Error(err))
|
||||
for _, p := range []string{outputPath, transcodedPath} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// uploadRecording 把本地 mp4 上传到 MinIO,返回可访问 URL 与文件大小
|
||||
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey string) (string, int64, error) {
|
||||
// uploadRecording 把本地录制文件上传到 MinIO,返回可访问 URL 与文件大小
|
||||
// contentType 由调用方根据实际产物(mp4 / webm)传入,避免错误的 Content-Type 让浏览器拒播
|
||||
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey, contentType string) (string, int64, error) {
|
||||
if localPath == "" {
|
||||
return "", 0, fmt.Errorf("本地录制文件路径为空")
|
||||
}
|
||||
@@ -465,8 +476,11 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
if _, err := s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectKey, f, stat.Size(), minio.PutObjectOptions{
|
||||
ContentType: "video/mp4",
|
||||
ContentType: contentType,
|
||||
}); err != nil {
|
||||
return "", 0, fmt.Errorf("PutObject 失败: %w", err)
|
||||
}
|
||||
@@ -479,6 +493,88 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
|
||||
return url, stat.Size(), nil
|
||||
}
|
||||
|
||||
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
|
||||
//
|
||||
// 方案 2 同步转码:
|
||||
// - 输入是 media-server 写出的 .webm(VP8/Opus)
|
||||
// - 优先 ffmpeg 同步转 .mp4(H264/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 +faststart:moov 前置,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 字符串)
|
||||
// webhook sink 入口前置查询,定位 Go 端主键
|
||||
func (s *MeetingRecordingService) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) {
|
||||
|
||||
Reference in New Issue
Block a user