feat: batch snap from mp4 file

This commit is contained in:
pggiroro
2025-08-21 22:39:16 +08:00
parent cabd0e3088
commit bc0c761aa8
7 changed files with 494 additions and 52 deletions

View File

@@ -2,6 +2,7 @@ package plugin_crontab
import (
"context"
"errors"
"fmt"
"sort"
"strings"
@@ -327,11 +328,9 @@ func (ct *CrontabPlugin) ListRecordPlanStreams(ctx context.Context, req *cronpb.
}
func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.PlanStream) (*cronpb.Response, error) {
if req.PlanId == 0 {
return &cronpb.Response{
Code: 400,
Message: "record_plan_id is required",
}, nil
planId := 1
if req.PlanId > 0 {
planId = int(req.PlanId)
}
if strings.TrimSpace(req.StreamPath) == "" {
@@ -342,7 +341,7 @@ func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.Pl
}
// 从内存中获取录制计划
plan, ok := ct.recordPlans.Get(uint(req.PlanId))
plan, ok := ct.recordPlans.Get(uint(planId))
if !ok {
return &cronpb.Response{
Code: 404,
@@ -353,7 +352,7 @@ func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.Pl
// 检查是否已存在相同的记录
var count int64
searchModel := pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
PlanID: uint(planId),
StreamPath: req.StreamPath,
}
if err := ct.DB.Model(&searchModel).Where(&searchModel).Count(&count).Error; err != nil {
@@ -370,10 +369,16 @@ func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.Pl
}, nil
}
fragment := "60s"
if req.Fragment != "" {
fragment = req.Fragment
}
stream := &pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
StreamPath: req.StreamPath,
Fragment: req.Fragment,
Fragment: fragment,
FilePath: req.FilePath,
Enable: req.Enable,
RecordType: req.RecordType,
@@ -406,11 +411,9 @@ func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.Pl
}
func (ct *CrontabPlugin) UpdateRecordPlanStream(ctx context.Context, req *cronpb.PlanStream) (*cronpb.Response, error) {
if req.PlanId == 0 {
return &cronpb.Response{
Code: 400,
Message: "record_plan_id is required",
}, nil
planId := 1
if req.PlanId > 0 {
planId = int(req.PlanId)
}
if strings.TrimSpace(req.StreamPath) == "" {
@@ -423,7 +426,7 @@ func (ct *CrontabPlugin) UpdateRecordPlanStream(ctx context.Context, req *cronpb
// 检查记录是否存在
var existingStream pkg.RecordPlanStream
searchModel := pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
PlanID: uint(planId),
StreamPath: req.StreamPath,
}
if err := ct.DB.Where(&searchModel).First(&existingStream).Error; err != nil {
@@ -524,7 +527,7 @@ func (ct *CrontabPlugin) RemoveRecordPlanStream(ctx context.Context, req *cronpb
// 停止所有相关的定时任务
ct.crontabs.Range(func(crontab *Crontab) bool {
if crontab.RecordPlanStream.StreamPath == req.StreamPath && crontab.RecordPlan.ID == uint(req.PlanId) {
crontab.Stop(nil)
crontab.Stop(errors.New("remove record plan"))
}
return true
})

View File

@@ -359,13 +359,16 @@ func (cron *Crontab) startRecording() {
// 发送开始录制请求
resp, err := http.Post(fmt.Sprintf("http://%s/mp4/api/start/%s", addr, cron.StreamPath), "application/json", bytes.NewBuffer(jsonBody))
cron.Debug("record request", "url is ", fmt.Sprintf("http://%s/mp4/api/start/%s", addr, cron.StreamPath), "jsonBody is ", string(jsonBody))
if err != nil {
time.Sleep(time.Second)
cron.Error("开始录制失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
time.Sleep(time.Second)
cron.Error("开始录制失败HTTP状态码: %d", resp.StatusCode)
return
}

View File

@@ -1,6 +1,7 @@
package plugin_crontab
import (
"gorm.io/gorm"
"strings"
"m7s.live/v5/plugin/crontab/pkg"
@@ -9,16 +10,44 @@ import (
// InitDefaultPlans 初始化默认的录制计划
// 包括工作日录制计划和周末录制计划
func (ct *CrontabPlugin) InitDefaultPlans() {
// 创建全天24小时录制计划七天全天录制的计划字符串
allDayPlanStr := buildPlanString(true, true, true, true, true, true, true) // 周日到周六
// 检查是否已存在相同内容的工作日录制计划
var count int64
if err := ct.DB.Model(&pkg.RecordPlan{}).Where("plan = ?", allDayPlanStr).Count(&count).Error; err != nil {
ct.Error("检查24小时录制计划失败: %v", err)
} else if count == 0 {
// 不存在相同内容的计划,创建新计划
workdayPlan := &pkg.RecordPlan{
Model: gorm.Model{ID: 1},
Name: "七天全天录制计划",
Plan: allDayPlanStr,
Enable: true,
}
if err := ct.DB.Create(workdayPlan).Error; err != nil {
ct.Error("创建七天全天录制计划失败: %v", err)
} else {
ct.Info("成功创建七天全天录制计划")
// 添加到内存中
ct.recordPlans.Add(workdayPlan)
}
} else {
ct.Info("已存在相同内容的七天全天录制计划,跳过创建")
}
// 创建工作日录制计划(周一到周五全天录制)的计划字符串
workdayPlanStr := buildPlanString(false, true, true, true, true, true, false) // 周一到周五
// 检查是否已存在相同内容的工作日录制计划
var count int64
if err := ct.DB.Model(&pkg.RecordPlan{}).Where("plan = ?", workdayPlanStr).Count(&count).Error; err != nil {
ct.Error("检查工作日录制计划失败: %v", err)
} else if count == 0 {
// 不存在相同内容的计划,创建新计划
workdayPlan := &pkg.RecordPlan{
Model: gorm.Model{ID: 2},
Name: "工作日录制计划",
Plan: workdayPlanStr,
Enable: true,
@@ -44,6 +73,7 @@ func (ct *CrontabPlugin) InitDefaultPlans() {
} else if count == 0 {
// 不存在相同内容的计划,创建新计划
weekendPlan := &pkg.RecordPlan{
Model: gorm.Model{ID: 3},
Name: "周末录制计划",
Plan: weekendPlanStr,
Enable: true,
@@ -69,7 +99,7 @@ func buildPlanString(sun, mon, tue, wed, thu, fri, sat bool) string {
// 按照周日、周一、...、周六的顺序
days := []bool{sun, mon, tue, wed, thu, fri, sat}
for _, record := range days {
if record {
// 该天录制24小时都为1

View File

@@ -1,9 +1,40 @@
-- 初始化录制计划的 SQL 脚本
-- 包含个预设计划:工作日全天录制周末全天录制
-- 包含个预设计划:工作日全天录制,周末全天录制,每天全天录制
-- 24小时不间断录制计划每天全天录制
INSERT INTO record_plans (id, name, plan, enable, created_at, updated_at)
SELECT 1,'每天全天录制',
-- 168位的计划字符串格式为
-- 前24位为周日接着24位为周一以此类推到周六
-- 0表示不录制1表示录制
-- 工作日录制周一到周五全为1周六周日全为0
CONCAT(
-- 周日024个1
REPEAT('1', 24),
-- 周一124个1
REPEAT('1', 24),
-- 周二224个1
REPEAT('1', 24),
-- 周三324个1
REPEAT('1', 24),
-- 周四424个1
REPEAT('1', 24),
-- 周五524个1
REPEAT('1', 24),
-- 周六624个1
REPEAT('1', 24)
),
TRUE, -- 启用状态
NOW(), -- 创建时间
NOW() -- 更新时间
WHERE NOT EXISTS (
SELECT 1 FROM record_plans WHERE name = '每天全天录制'
);
-- 工作日计划(周一到周五全天录制)
INSERT INTO record_plans (name, plan, enable, created_at, updated_at)
SELECT '工作日录制计划',
INSERT INTO record_plans (id,name, plan, enable, created_at, updated_at)
SELECT 2,'工作日录制计划',
-- 168位的计划字符串格式为
-- 前24位为周日接着24位为周一以此类推到周六
-- 0表示不录制1表示录制
@@ -32,8 +63,8 @@ WHERE NOT EXISTS (
);
-- 周末计划(周六和周日全天录制)
INSERT INTO record_plans (name, plan, enable, created_at, updated_at)
SELECT '周末录制计划',
INSERT INTO record_plans (id,name, plan, enable, created_at, updated_at)
SELECT 3,'周末录制计划',
-- 168位的计划字符串
-- 周末录制周六周日全为1周一到周五全为0
CONCAT(

View File

@@ -8,7 +8,7 @@ import (
type RecordPlan struct {
gorm.Model
Name string `json:"name" gorm:"default:''"`
Plan string `json:"plan" gorm:"type:text"`
Plan string `json:"plan" gorm:"type:varchar(255)"`
Enable bool `json:"enable" gorm:"default:false"` // 是否启用
}

View File

@@ -9,7 +9,7 @@ import (
type RecordPlanStream struct {
PlanID uint `json:"plan_id" gorm:"primaryKey;type:bigint;not null"` // 录制计划ID
StreamPath string `json:"stream_path" gorm:"primaryKey;type:varchar(255)"`
Fragment string `json:"fragment" gorm:"type:text"`
Fragment string `json:"fragment" gorm:"type:varchar(255)"`
FilePath string `json:"file_path" gorm:"type:varchar(255)"`
CreatedAt time.Time
UpdatedAt time.Time

View File

@@ -9,8 +9,10 @@ import (
_ "image/jpeg"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
@@ -158,7 +160,7 @@ func (p *SnapPlugin) doSnap(rw http.ResponseWriter, r *http.Request) {
// 处理保存逻辑
savePath := query.Get("savePath")
now := time.Now()
now := time.Now().UTC()
if savePath != "" {
os.Mkdir(savePath, 0755)
filename := fmt.Sprintf("%s_%s.jpg", streamPath, now.Format("20060102150405.000"))
@@ -255,7 +257,8 @@ func (p *SnapPlugin) querySnap(rw http.ResponseWriter, r *http.Request) {
return
}
targetTime := time.Unix(snapTimeUnix+1, 0)
// 将时间戳转换为UTC时间确保与数据库中存储的UTC时间一致
targetTime := time.Unix(snapTimeUnix+1, 0).UTC()
var record snap_pkg.SnapRecord
// 查询小于等于目标时间的最近一条记录
@@ -363,17 +366,17 @@ func (p *SnapPlugin) batchSnap(rw http.ResponseWriter, r *http.Request) {
// 检查截图时间点是否为空
if len(snapTimes) == 0 {
p.Warn("no valid snapshot times available",
p.Warn("no valid snapshot times available",
"streamPath", streamPath,
"startTime", startTime.Format(time.RFC3339),
"endTime", endTime.Format(time.RFC3339),
"granularity", granularity)
response := BatchSnapResponse{
Success: false,
Message: "No valid snapshot times available. Please check your time range and try again.",
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(response)
return
@@ -408,7 +411,7 @@ func (p *SnapPlugin) calculateSnapTimes(publisher *m7s.Publisher, startTime, end
// 检查开始时间是否早于当前时间,如果是,则从当前时间开始
now := time.Now()
if startTime.Before(now) {
p.Info("adjusting start time from past to current time",
p.Info("adjusting start time from past to current time",
"originalStartTime", startTime.Format(time.RFC3339),
"adjustedStartTime", now.Format(time.RFC3339))
startTime = now
@@ -416,7 +419,7 @@ func (p *SnapPlugin) calculateSnapTimes(publisher *m7s.Publisher, startTime, end
// 检查结束时间是否晚于开始时间
if endTime.Before(startTime) || endTime.Equal(startTime) {
p.Warn("invalid time range: end time is not after start time",
p.Warn("invalid time range: end time is not after start time",
"startTime", startTime.Format(time.RFC3339),
"endTime", endTime.Format(time.RFC3339))
return nil
@@ -455,17 +458,17 @@ func (p *SnapPlugin) calculateSnapTimes(publisher *m7s.Publisher, startTime, end
if idrRing != nil {
// 将时间戳转换为time.Time从纳秒转为秒
keyframeTime := time.Unix(0, int64(idrRing.Value.Timestamp))
// 检查是否在指定时间范围内
if (keyframeTime.Equal(startTime) || keyframeTime.After(startTime)) &&
(keyframeTime.Equal(endTime) || keyframeTime.Before(endTime)) {
if (keyframeTime.Equal(startTime) || keyframeTime.After(startTime)) &&
(keyframeTime.Equal(endTime) || keyframeTime.Before(endTime)) {
snapTimes = append(snapTimes, keyframeTime)
}
}
}
}
videoTrack.RUnlock()
// 如果没有找到关键帧但有GOP信息则使用估算的GOP间隔生成时间点
if len(snapTimes) == 0 && gopDuration > 0 {
p.Info("no keyframes found in range, using estimated GOP interval")
@@ -509,12 +512,12 @@ func (p *SnapPlugin) executeBatchSnapTask(publisher *m7s.Publisher, streamPath s
now := time.Now()
if firstSnapTime.After(now) {
waitDuration := firstSnapTime.Sub(now)
p.Info("batch snap task scheduled for future",
"streamPath", streamPath,
"totalSnapshots", len(snapTimes),
p.Info("batch snap task scheduled for future",
"streamPath", streamPath,
"totalSnapshots", len(snapTimes),
"startTime", firstSnapTime.Format(time.RFC3339),
"waitDuration", waitDuration.String())
// 等待到开始时间
time.Sleep(waitDuration)
}
@@ -531,7 +534,7 @@ func (p *SnapPlugin) executeBatchSnapTask(publisher *m7s.Publisher, streamPath s
for i, snapTime := range snapTimes {
// 打印日志,记录当前截图时间和进度
p.Debug("taking snapshot", "progress", fmt.Sprintf("%d/%d", i+1, len(snapTimes)), "time", snapTime.Format(time.RFC3339))
// 当前实现不支持指定时间截图,所以这里只能截取当前帧
// 注意:这里每次都会重新创建一个读取器,确保获取到最新的帧
buf, err := p.snap(publisher, nil)
@@ -540,7 +543,7 @@ func (p *SnapPlugin) executeBatchSnapTask(publisher *m7s.Publisher, streamPath s
failCount++
continue
}
// 如果是按间隔截图,每次截图后等待指定时间
if granularity > 0 && i < len(snapTimes)-1 { // 不是最后一帧才需要等待
// 等待granularity秒确保下一次截图与当前截图有足够的时间差
@@ -565,7 +568,7 @@ func (p *SnapPlugin) executeBatchSnapTask(publisher *m7s.Publisher, streamPath s
record := snap_pkg.SnapRecord{
StreamName: streamPath,
SnapMode: 3, // 批量截图模式
SnapTime: snapTime,
SnapTime: snapTime.UTC(),
SnapPath: filePath,
}
if err := p.DB.Create(&record).Error; err != nil {
@@ -579,20 +582,392 @@ func (p *SnapPlugin) executeBatchSnapTask(publisher *m7s.Publisher, streamPath s
// 记录任务完成时间和结果
taskEndTime := time.Now()
taskDuration := taskEndTime.Sub(taskStartTime)
p.Info("batch snap task completed",
"streamPath", streamPath,
"total", len(snapTimes),
"success", successCount,
"failed", failCount,
p.Info("batch snap task completed",
"streamPath", streamPath,
"total", len(snapTimes),
"success", successCount,
"failed", failCount,
"duration", taskDuration.String())
}
// batchPlayBack 处理从MP4录像文件中按时间范围和颗粒度进行截图的请求
func (p *SnapPlugin) batchPlayBack(rw http.ResponseWriter, r *http.Request) {
// 只接受GET请求
if r.Method != http.MethodGet {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查数据库连接
if p.DB == nil {
responseWithError(rw, "数据库未初始化")
return
}
// 获取streamPath
streamPath := r.PathValue("streamPath")
if streamPath == "" {
responseWithError(rw, "streamPath参数必须提供")
return
}
// 获取查询参数
query := r.URL.Query()
// 解析时间范围
startTime, endTime, err := util.TimeRangeQueryParse(query)
if err != nil {
responseWithError(rw, "无效的时间范围: "+err.Error())
return
}
// 验证时间范围
if endTime.Before(startTime) {
responseWithError(rw, "结束时间必须晚于开始时间")
return
}
// 获取granularity参数
granularity := 0
granularityStr := query.Get("granularity")
if granularityStr != "" {
granularityVal, err := strconv.Atoi(granularityStr)
if err != nil {
responseWithError(rw, "无效的颗粒度格式: "+err.Error())
return
}
if granularityVal < 0 {
responseWithError(rw, "颗粒度必须为非负数")
return
}
granularity = granularityVal
}
// 创建保存目录
savePath := filepath.Join("snap", "playback", streamPath)
os.MkdirAll(savePath, 0755)
savePath = strings.ReplaceAll(savePath, "/", "_")
os.MkdirAll(savePath, 0755)
// 立即返回成功响应,表示任务已接收
response := BatchSnapResponse{
Success: true,
Message: fmt.Sprintf("回放截图任务已开始。正在后台处理。时间范围: %s 到 %s (使用参数 start 和 end)", startTime.Format(time.RFC3339), endTime.Format(time.RFC3339)),
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(response)
// 在后台异步执行截图任务
go p.executePlayBackSnapTask(streamPath, startTime, endTime, savePath, granularity)
}
// executePlayBackSnapTask 在后台执行从MP4录像文件中截图的任务
func (p *SnapPlugin) executePlayBackSnapTask(streamPath string, startTime, endTime time.Time, savePath string, granularity int) {
// 记录任务开始时间
taskStartTime := time.Now()
p.Info("playback snap task started", "streamPath", streamPath, "startTime", startTime, "endTime", endTime)
// 从数据库中查询指定时间范围内的MP4录像文件
var streams []m7s.RecordStream
queryRecord := m7s.RecordStream{
Type: "mp4",
}
// 查询条件:结束时间大于请求的开始时间,开始时间小于请求的结束时间,流路径匹配
p.DB.Where(&queryRecord).Find(&streams, "end_time>? AND start_time<? AND stream_path=?", startTime, endTime, streamPath)
// 检查是否找到录像文件
if len(streams) == 0 {
p.Warn("no mp4 records found for playback snap", "streamPath", streamPath, "startTime", startTime, "endTime", endTime)
return
}
p.Info("found mp4 records for playback snap", "streamPath", streamPath, "count", len(streams))
// 按开始时间排序录像文件,确保时间连续性
sort.Slice(streams, func(i, j int) bool {
return streams[i].StartTime.Before(streams[j].StartTime)
})
// 全局截图时间点列表
var allSnapTimes []time.Time
// 如果颜粒度小于等于0则对每个文件提取关键帧
if granularity <= 0 {
// 对每个文件分别提取关键帧
for _, stream := range streams {
// 检查文件是否存在
if _, err := os.Stat(stream.FilePath); os.IsNotExist(err) {
p.Warn("mp4 file not found", "path", stream.FilePath)
continue
}
// 计算此文件的有效时间范围(与请求时间范围的交集)
fileStartTime := stream.StartTime
if fileStartTime.Before(startTime) {
fileStartTime = startTime
}
fileEndTime := stream.EndTime
if fileEndTime.After(endTime) {
fileEndTime = endTime
}
// 提取关键帧
keyFrameTimes, err := p.extractKeyFrameTimes(stream.FilePath, fileStartTime, fileEndTime)
if err != nil {
p.Error("extract key frames failed", "error", err.Error())
// 如果提取失败使用默认的每2秒截图
defaultGranularity := 2 * time.Second
for t := fileStartTime; t.Before(fileEndTime); t = t.Add(defaultGranularity) {
allSnapTimes = append(allSnapTimes, t)
}
} else {
// 将关键帧时间点添加到全局列表
allSnapTimes = append(allSnapTimes, keyFrameTimes...)
}
}
} else {
// 当指定颜粒度时,基于整个时间范围生成均匀的截图时间点
// 这样可以确保在不同文件之间保持一致的颜粒度
for t := startTime; t.Before(endTime); t = t.Add(time.Duration(granularity) * time.Second) {
allSnapTimes = append(allSnapTimes, t)
}
}
// 按时间排序并去重
sort.Slice(allSnapTimes, func(i, j int) bool {
return allSnapTimes[i].Before(allSnapTimes[j])
})
// 去除重复的时间点(如果有)
var uniqueSnapTimes []time.Time
if len(allSnapTimes) > 0 {
uniqueSnapTimes = append(uniqueSnapTimes, allSnapTimes[0])
for i := 1; i < len(allSnapTimes); i++ {
// 如果与前一个时间点不同,则添加
if !allSnapTimes[i].Equal(allSnapTimes[i-1]) {
uniqueSnapTimes = append(uniqueSnapTimes, allSnapTimes[i])
}
}
}
p.Info("generated snapshot times", "count", len(uniqueSnapTimes))
// 处理每个截图时间点
var successCount, failCount int
for _, snapTime := range uniqueSnapTimes {
// 找到包含该时间点的录像文件
var targetStream *m7s.RecordStream
for j := range streams {
if (snapTime.Equal(streams[j].StartTime) || snapTime.After(streams[j].StartTime)) &&
(snapTime.Equal(streams[j].EndTime) || snapTime.Before(streams[j].EndTime)) {
targetStream = &streams[j]
break
}
}
// 如果找不到对应的文件,跳过该时间点
if targetStream == nil {
p.Warn("no mp4 file found for time point", "time", snapTime.Format(time.RFC3339))
failCount++
continue
}
// 检查文件是否存在
if _, err := os.Stat(targetStream.FilePath); os.IsNotExist(err) {
p.Warn("mp4 file not found", "path", targetStream.FilePath)
failCount++
continue
}
// 计算在文件中的时间偏移(毫秒)
// 使用文件的duration字段来计算时间偏移
// 首先计算截图时间点在整个文件时间范围内的相对位置
fileStartTime := targetStream.StartTime
fileEndTime := targetStream.EndTime
fileDuration := targetStream.Duration
// 如果数据库中的duration字段有效则使用它来计算时间偏移
var timeOffset int64
if fileDuration > 0 {
// 注意duration字段存储的是毫秒值如 69792 表示 69.792 秒
// 计算截图时间点在整个文件时间范围内的相对位置(百分比)
totalDuration := fileEndTime.Sub(fileStartTime).Milliseconds()
if totalDuration > 0 {
position := float64(snapTime.Sub(fileStartTime).Milliseconds()) / float64(totalDuration)
// 根据百分比位置和实际duration计算出时间偏移
// duration已经是毫秒值直接使用
timeOffset = int64(position * float64(fileDuration))
p.Debug("using duration for time offset calculation", "position", position, "duration_ms", fileDuration, "timeOffset_ms", timeOffset)
} else {
// 如果计算出问题,回退到直接使用时间差
timeOffset = snapTime.Sub(fileStartTime).Milliseconds()
p.Debug("fallback to direct time difference", "timeOffset", timeOffset)
}
} else {
// 如果duration无效则使用时间差
timeOffset = snapTime.Sub(fileStartTime).Milliseconds()
p.Debug("invalid duration, using time difference", "timeOffset", timeOffset)
}
// 使用FFmpeg从MP4文件中截取指定时间点的图片
// 文件名包含截图时间点和颜粒度信息,避免不同颜粒度的截图相互覆盖
var granularityInfo string
if granularity <= 0 {
granularityInfo = "keyframe"
} else {
granularityInfo = fmt.Sprintf("%ds", granularity)
}
filename := fmt.Sprintf("%s_%s_%s.jpg",
streamPath,
snapTime.Format("20060102150405"),
granularityInfo)
filename = strings.ReplaceAll(filename, "/", "_")
filePath := filepath.Join(savePath, filename)
// 调用截图函数
err := p.snapFromMP4(targetStream.FilePath, filePath, timeOffset)
if err != nil {
p.Error("playback snap failed", "error", err.Error(), "time", snapTime.Format(time.RFC3339))
failCount++
continue
}
// 保存截图记录到数据库
if p.DB != nil {
record := snap_pkg.SnapRecord{
StreamName: streamPath,
SnapMode: 4, // 回放截图模式
SnapTime: snapTime,
SnapPath: filePath,
}
if err := p.DB.Create(&record).Error; err != nil {
p.Error("save playback snapshot record failed", "error", err.Error())
}
}
successCount++
}
// 记录任务完成时间和结果
taskEndTime := time.Now()
taskDuration := taskEndTime.Sub(taskStartTime)
p.Info("playback snap task completed",
"streamPath", streamPath,
"success", successCount,
"failed", failCount,
"duration", taskDuration.String())
}
// snapFromMP4 从MP4文件中截取指定时间点的图片
func (p *SnapPlugin) snapFromMP4(mp4FilePath, outputPath string, timeOffsetMs int64) error {
// 将时间偏移转换为秒
timeOffsetSec := float64(timeOffsetMs) / 1000.0
// 构建ffmpeg命令
cmd := exec.Command(
"ffmpeg",
"-hide_banner",
"-ss", fmt.Sprintf("%f", timeOffsetSec), // 设置时间偏移
"-i", mp4FilePath, // 输入文件
"-vframes", "1", // 只截取一帧
"-q:v", "2", // 设置图片质量
"-y", // 覆盖输出文件
outputPath, // 输出文件路径
)
// 执行命令
output, err := cmd.CombinedOutput()
if err != nil {
p.Error("ffmpeg command failed", "error", err.Error(), "output", string(output))
return fmt.Errorf("ffmpeg error: %s, output: %s", err.Error(), string(output))
}
return nil
}
// extractKeyFrameTimes 从MP4文件中提取关键帧时间点
func (p *SnapPlugin) extractKeyFrameTimes(mp4FilePath string, startTime, endTime time.Time) ([]time.Time, error) {
// 使用FFmpeg的-skip_frame nokey参数和-show_entries frame=pkt_pts_time参数提取关键帧时间
cmd := exec.Command(
"ffprobe",
"-v", "quiet",
"-select_streams", "v",
"-skip_frame", "nokey", // 只处理关键帧
"-show_entries", "frame=pkt_pts_time", // 显示帧的时间戳
"-of", "csv=p=0", // 输出为CSV格式
"-i", mp4FilePath,
)
// 执行命令
output, err := cmd.CombinedOutput()
if err != nil {
p.Error("ffprobe command failed", "error", err.Error(), "output", string(output))
return nil, fmt.Errorf("ffprobe error: %s", err.Error())
}
// 解析输出结果,提取时间戳
lines := strings.Split(string(output), "\n")
// 获取MP4文件的开始时间信息
// 注意ffprobe返回的时间戳是相对于文件开始的秒数
// 我们需要将其转换为绝对时间
fileStartTimeUnix := time.Time{}
// 使用数据库中记录的文件开始时间
// 查询数据库获取文件信息
var fileInfo m7s.RecordStream
if err := p.DB.Where("file_path = ?", mp4FilePath).First(&fileInfo).Error; err == nil {
fileStartTimeUnix = fileInfo.StartTime
} else {
p.Warn("failed to get file start time from database, using request start time", "error", err.Error())
fileStartTimeUnix = startTime
}
p.Info("file start time", "time", fileStartTimeUnix.Format(time.RFC3339))
// 存储关键帧时间点
var keyFrameTimes []time.Time
// 处理每一行输出
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 将时间戳转换为浮点数(秒)
timeOffsetSec, err := strconv.ParseFloat(line, 64)
if err != nil {
p.Warn("invalid time format in ffprobe output", "line", line)
continue
}
// 计算实际时间:文件开始时间 + 偏移秒数
frameTime := fileStartTimeUnix.Add(time.Duration(timeOffsetSec * float64(time.Second)))
// 只保留在请求时间范围内的关键帧
if (frameTime.Equal(startTime) || frameTime.After(startTime)) &&
(frameTime.Equal(endTime) || frameTime.Before(endTime)) {
keyFrameTimes = append(keyFrameTimes, frameTime)
}
}
// 如果没有找到关键帧,返回错误
if len(keyFrameTimes) == 0 {
return nil, fmt.Errorf("no key frames found in the specified time range")
}
return keyFrameTimes, nil
}
func (p *SnapPlugin) RegisterHandler() map[string]http.HandlerFunc {
return map[string]http.HandlerFunc{
"/{streamPath...}": p.doSnap,
"/query/{streamPath...}": p.querySnap,
"/batch/{streamPath...}": p.batchSnap,
"/{streamPath...}": p.doSnap,
"/query/{streamPath...}": p.querySnap,
"/batch/{streamPath...}": p.batchSnap,
"/batchplayback/{streamPath...}": p.batchPlayBack,
}
}