diff --git a/backend/go-service/app/meeting/service/meeting_recording_service.go b/backend/go-service/app/meeting/service/meeting_recording_service.go index 7fd66c6..f12eba0 100644 --- a/backend/go-service/app/meeting/service/meeting_recording_service.go +++ b/backend/go-service/app/meeting/service/meeting_recording_service.go @@ -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) {