diff --git a/api/api.go b/api/api.go index b5080ae..1e1ff01 100644 --- a/api/api.go +++ b/api/api.go @@ -33,6 +33,7 @@ type InviteParams struct { type StreamParams struct { Stream common.StreamID `json:"stream"` // Source + Session string `json:"session"` // 本次推拉流的会话ID Protocol int `json:"protocol"` // 推拉流协议 RemoteAddr string `json:"remote_addr"` // peer地址 } @@ -74,6 +75,12 @@ type RecordParams struct { Path string `json:"path"` } +type SnapshotParams struct { + StreamParams + Codec string `json:"codec"` + KeyFrameData []byte `json:"key_frame_data"` +} + type StreamIDParams struct { StreamID common.StreamID `json:"streamid"` Command string `json:"command"` @@ -261,6 +268,7 @@ func StartApiServer(addr string) { apiServer.router.HandleFunc("/api/v1/hook/on_receive_timeout", common.WithJsonParams(apiServer.OnReceiveTimeout, &StreamParams{})) apiServer.router.HandleFunc("/api/v1/hook/on_record", common.WithJsonParams(apiServer.OnRecord, &RecordParams{})) apiServer.router.HandleFunc("/api/v1/hook/on_started", apiServer.OnStarted) + apiServer.router.HandleFunc("/api/v1/hook/on_snapshot", common.WithJsonParams(apiServer.OnSnapshot, &SnapshotParams{})) apiServer.registerStatisticsHandler("开始预览", "/api/v1/stream/start", withVerify(common.WithFormDataParams(apiServer.OnStreamStart, InviteParams{}))) // 实时预览 apiServer.registerStatisticsHandler("停止预览", "/api/v1/stream/stop", withVerify(common.WithFormDataParams(apiServer.OnCloseLiveStream, InviteParams{}))) // 关闭实时预览 @@ -315,6 +323,7 @@ func StartApiServer(addr string) { apiServer.router.HandleFunc("/api/v1/getrequestkey", withVerify(func(w http.ResponseWriter, req *http.Request) {})) apiServer.router.HandleFunc("/api/v1/device/positionlog", withVerify(func(w http.ResponseWriter, req *http.Request) {})) apiServer.router.HandleFunc("/api/v1/device/streamlog", withVerify(func(w http.ResponseWriter, req *http.Request) {})) + apiServer.router.HandleFunc("/api/v1/record/list", withVerify(func(w http.ResponseWriter, req *http.Request) {})) apiServer.registerStatisticsHandler("开始录制", "/api/v1/record/start", withVerify(apiServer.OnRecordStart)) // 开启录制 apiServer.registerStatisticsHandler("结束录制", "/api/v1/record/stop", withVerify(apiServer.OnRecordStop)) // 关闭录制 @@ -401,6 +410,9 @@ func StartApiServer(addr string) { }) }) + // 映射snapshot目录 + apiServer.router.PathPrefix("/snapshot/").Handler(http.StripPrefix("/snapshot/", http.FileServer(http.Dir("./snapshot")))) + // 前端路由 htmlRoot := "./html/" fileServer := http.FileServer(http.Dir(htmlRoot)) diff --git a/api/api_device.go b/api/api_device.go index 9cc93d8..184b7a4 100644 --- a/api/api_device.go +++ b/api/api_device.go @@ -326,7 +326,12 @@ func (api *ApiServer) OnDeviceTree(q *QueryDeviceChannel, _ http.ResponseWriter, onlineCount, _ = dao.Channel.QueryOnlineSubChannelCount(channel.RootID, channel.DeviceID, false) } - response = append(response, &LiveGBSDeviceTree{Code: channel.DeviceID, Custom: false, CustomID: "", CustomName: "", ID: id, Latitude: latitude, Longitude: longitude, Manufacturer: channel.Manufacturer, Name: channel.Name, OnlineSubCount: onlineCount, Parental: false, PtzType: 0, Serial: channel.RootID, Status: channel.Status.String(), SubCount: channel.SubCount, SubCountDevice: deviceCount}) + var ptzType int + if channel.Info != nil { + ptzType, _ = strconv.Atoi(channel.Info.PTZType) + } + + response = append(response, &LiveGBSDeviceTree{Code: channel.DeviceID, Custom: false, CustomID: "", CustomName: "", ID: id, Latitude: latitude, Longitude: longitude, Manufacturer: channel.Manufacturer, Name: channel.Name, OnlineSubCount: onlineCount, Parental: false, PtzType: ptzType, Serial: channel.RootID, Status: channel.Status.String(), SubCount: channel.SubCount, SubCountDevice: deviceCount}) } } diff --git a/api/api_hook.go b/api/api_hook.go index f92aa0f..7dcdea7 100644 --- a/api/api_hook.go +++ b/api/api_hook.go @@ -6,11 +6,16 @@ import ( "gb-cms/hook" "gb-cms/log" "gb-cms/stack" + "github.com/csnewman/ffmpeg-go" "github.com/lkmio/avformat/utils" "net/http" + "os" + "path" "strings" ) +var VideoKeyFrame2JPG func(codecId ffmpeg.AVCodecID, h264Data []byte, dstPath string) error + func (api *ApiServer) OnPlay(params *PlayDoneParams, w http.ResponseWriter, r *http.Request) { log.Sugar.Infof("播放事件. protocol: %s stream: %s", params.Protocol, params.Stream) @@ -154,6 +159,7 @@ func (api *ApiServer) OnPublish(params *StreamParams, w http.ResponseWriter, _ * stream := stack.EarlyDialogs.Find(string(params.Stream)) if stream != nil { + stream.Data = params.Session stream.Put(200) } else { log.Sugar.Infof("推流事件. 未找到stream. stream: %s", params.Stream) @@ -227,3 +233,44 @@ func (api *ApiServer) OnStarted(_ http.ResponseWriter, _ *http.Request) { (&stack.Sink{SinkModel: sink}).Close(true, false) } } + +func (api *ApiServer) OnSnapshot(v *SnapshotParams, writer http.ResponseWriter, request *http.Request) { + if VideoKeyFrame2JPG == nil { + return + } + + var codecId ffmpeg.AVCodecID + switch strings.ToLower(v.Codec) { + case "h264": + codecId = ffmpeg.AVCodecIdH264 + case "h265": + codecId = ffmpeg.AVCodecIdH265 + default: + log.Sugar.Errorf("不支持的视频编码格式 codec: %s", v.Codec) + return + } + + jpgPath := GetSnapshotPath(v.Stream, v.Session) + err := os.MkdirAll(path.Dir(jpgPath), 0755) + if err != nil { + log.Sugar.Errorf("创建目录失败 err: %s", err.Error()) + return + } + + // 关家帧转换并保存JPEG + err = VideoKeyFrame2JPG(codecId, v.KeyFrameData, jpgPath) + if err != nil { + log.Sugar.Errorf("转换为JPEG失败 err: %s", err.Error()) + } else if err = dao.Channel.SetSnapshotPath(v.Stream.DeviceID(), v.Stream.ChannelID(), jpgPath); err != nil { + // 数据库更新通道的最新截图 + log.Sugar.Errorf("更新通道最新截图失败 err: %s", err.Error()) + } +} + +func GetSnapshotPath(streamID common.StreamID, sessionID string) string { + if VideoKeyFrame2JPG == nil { + return "" + } + + return path.Join("./snapshot/", string(streamID), sessionID+".jpg") +} diff --git a/api/api_stream.go b/api/api_stream.go index 8ee2aa8..e5e4935 100644 --- a/api/api_stream.go +++ b/api/api_stream.go @@ -95,7 +95,7 @@ func (api *ApiServer) DoStartStream(v *InviteParams, w http.ResponseWriter, r *h } response := LiveGBSStream{ - AudioEnable: false, + AudioEnable: true, CDN: "", CascadeSize: 0, ChannelID: v.ChannelID, @@ -116,7 +116,7 @@ func (api *ApiServer) DoStartStream(v *InviteParams, w http.ResponseWriter, r *h RecordStartAt: "", RelaySize: 0, SMSID: "", - SnapURL: "", + SnapURL: GetSnapshotPath(stream.StreamID, stream.SessionID), SourceAudioCodecName: "", SourceAudioSampleRate: 0, SourceVideoCodecName: "", diff --git a/api/api_system.go b/api/api_system.go index c5c2c80..5a07e8d 100644 --- a/api/api_system.go +++ b/api/api_system.go @@ -148,7 +148,7 @@ func (api *ApiServer) OnSetBaseConfig(baseConfig *BaseConfig, _ http.ResponseWri } // 更新优先流格式 - if baseConfig.PreferStreamFmt != "" && baseConfig.PreferStreamFmt != common.Config.PreferStreamFmt { + if baseConfig.PreferStreamFmt != common.Config.PreferStreamFmt { iniConfig.Section("sip").Key("prefer_stream_fmt").SetValue(baseConfig.PreferStreamFmt) changed = true } diff --git a/api/livegbs_bean.go b/api/livegbs_bean.go index 1ca7576..e14cd88 100644 --- a/api/livegbs_bean.go +++ b/api/livegbs_bean.go @@ -307,7 +307,7 @@ func ChannelModels2LiveGBSChannels(index int, channels []*dao.ChannelModel, devi SerialNumber: "", Shared: false, SignalLevel: 0, - SnapURL: "", + SnapURL: channel.SnapshotPath, Speed: 0, Status: channel.Status.String(), StreamID: string(streamID), // 实时流ID diff --git a/api/snapshot.go b/api/snapshot.go new file mode 100644 index 0000000..cee0a7b --- /dev/null +++ b/api/snapshot.go @@ -0,0 +1,120 @@ +//go:build (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (amd64 && windows) + +package api + +import ( + "fmt" + "github.com/csnewman/ffmpeg-go" + "os" + "unsafe" +) + +func init() { + VideoKeyFrame2JPG = videoKeyFrame2JPG +} + +func videoKeyFrame2JPG(codecId ffmpeg.AVCodecID, h264Data []byte, dstPath string) error { + // 2. 创建解码器 + codec := ffmpeg.AVCodecFindDecoder(codecId) + if codec == nil { + return fmt.Errorf("找不到解码器 %v", codecId) + } + + codecCtx := ffmpeg.AVCodecAllocContext3(codec) + defer ffmpeg.AVCodecFreeContext(&codecCtx) + + // 3. 打开解码器 + if _, err := ffmpeg.AVCodecOpen2(codecCtx, codec, nil); err != nil { + return fmt.Errorf("打开解码器失败 %v", err) + } + + // 4. 创建AVPacket并填充数据 + pkt := ffmpeg.AVPacketAlloc() + defer ffmpeg.AVPacketFree(&pkt) + + // 将H264数据拷贝到AVPacket + pktBuf := ffmpeg.AVMalloc(uint64(len(h264Data))) + copy(unsafe.Slice((*byte)(pktBuf), len(h264Data)), h264Data) + pkt.SetData(pktBuf) + pkt.SetSize(len(h264Data)) + pkt.SetFlags(pkt.Flags() | ffmpeg.AVPktFlagKey) // 标记为关键帧 + + // 5. 解码 + frame := ffmpeg.AVFrameAlloc() + defer ffmpeg.AVFrameFree(&frame) + + // 发送数据包到解码器 + if _, err := ffmpeg.AVCodecSendPacket(codecCtx, pkt); err != nil { + return fmt.Errorf("发送包失败 %v", err) + } + + // 接收解码后的帧 + if _, err := ffmpeg.AVCodecReceiveFrame(codecCtx, frame); err != nil { + return fmt.Errorf("解码失败 %v", err) + } + + // 6. 保存为JPEG + err := saveFrameAsJPEG(frame, dstPath) + return err +} + +func saveFrameAsJPEG(frame *ffmpeg.AVFrame, filename string) error { + // 创建JPEG编码器 + codec := ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdMjpeg) + if codec == nil { + return fmt.Errorf("找不到JPEG编码器") + } + + codecCtx := ffmpeg.AVCodecAllocContext3(codec) + defer ffmpeg.AVCodecFreeContext(&codecCtx) + + // 设置编码参数 + codecCtx.SetPixFmt(ffmpeg.AVPixFmtYuvj420P) + codecCtx.SetWidth(frame.Width()) + codecCtx.SetHeight(frame.Height()) + rational := ffmpeg.AVRational{} + rational.SetNum(1) + rational.SetDen(25) + codecCtx.SetTimeBase(&rational) + + codecCtx.SetColorspace(frame.Colorspace()) + codecCtx.SetColorRange(frame.ColorRange()) + + strict := ffmpeg.ToCStr("strict") + defer strict.Free() + + if _, err := ffmpeg.AVOptSetInt(codecCtx.RawPtr(), strict, ffmpeg.FFComplianceUnofficial, 0); err != nil { + return fmt.Errorf("警告: 设置strict参数失败 %v", err) + } + + // 打开编码器 + if _, err := ffmpeg.AVCodecOpen2(codecCtx, codec, nil); err != nil { + return fmt.Errorf("打开JPEG编码器失败 %v", err) + } + + // 创建输出文件 + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("创建文件失败 %v", err) + } + defer file.Close() + + // 编码帧 + pkt := ffmpeg.AVPacketAlloc() + defer ffmpeg.AVPacketFree(&pkt) + + if _, err := ffmpeg.AVCodecSendFrame(codecCtx, frame); err != nil { + return fmt.Errorf("发送帧失败 %v", err) + } + + if _, err := ffmpeg.AVCodecReceivePacket(codecCtx, pkt); err != nil { + return fmt.Errorf("接收包失败 %v", err) + } + + // 写入文件 + if _, err := file.Write(unsafe.Slice((*byte)(pkt.Data()), pkt.Size())); err != nil { + return fmt.Errorf("写入文件失败 %v", err) + } + + return nil +} diff --git a/avcodec-60.dll b/avcodec-60.dll new file mode 100644 index 0000000..9372d69 Binary files /dev/null and b/avcodec-60.dll differ diff --git a/avdevice-60.dll b/avdevice-60.dll new file mode 100644 index 0000000..0a36981 Binary files /dev/null and b/avdevice-60.dll differ diff --git a/avfilter-9.dll b/avfilter-9.dll new file mode 100644 index 0000000..2e3d076 Binary files /dev/null and b/avfilter-9.dll differ diff --git a/avformat-60.dll b/avformat-60.dll new file mode 100644 index 0000000..eef0647 Binary files /dev/null and b/avformat-60.dll differ diff --git a/avutil-58.dll b/avutil-58.dll new file mode 100644 index 0000000..a92b27a Binary files /dev/null and b/avutil-58.dll differ diff --git a/dao/channel.go b/dao/channel.go index 956895c..b42276e 100644 --- a/dao/channel.go +++ b/dao/channel.go @@ -88,6 +88,7 @@ type ChannelModel struct { CustomID *string `gorm:"unique" xml:"-"` // 自定义通道ID Event string `json:"-" xml:"Event,omitempty" gorm:"-"` // DropMark int `json:"-" xml:"-"` // 是否被过滤 0-不被过滤/非0-被过滤 + SnapshotPath string `json:"snapshot_path" xml:"-"` // 快照路径 } func (d *ChannelModel) TableName() string { @@ -430,3 +431,7 @@ func (d *daoChannel) DropChannel(rootId string, typeCodes []string, tx *gorm.DB) return update(tx) }) } + +func (d *daoChannel) SetSnapshotPath(rootId string, channelId string, snapshotPath string) error { + return db.Model(&ChannelModel{}).Where("root_id =? and device_id =?", rootId, channelId).Update("snapshot_path", snapshotPath).Error +} diff --git a/dao/stream.go b/dao/stream.go index 198d924..c55282b 100644 --- a/dao/stream.go +++ b/dao/stream.go @@ -12,6 +12,7 @@ type StreamModel struct { DeviceID string `gorm:"index"` // 下级设备ID, 统计某个设备的所有流/1078设备为sim number ChannelID string `gorm:"index"` // 下级通道ID, 统计某个设备下的某个通道的所有流/1078设备为 channel number StreamID common.StreamID `json:"stream_id" gorm:"index,unique"` // 流ID + SessionID string `gorm:"index"` // 某次推流的会话ID Protocol int `json:"protocol,omitempty"` // 推流协议, @See stack.SourceTypeRtmp StreamType string // play/playback/download Dialog *common.RequestWrapper `json:"dialog,omitempty"` // 国标流的SipCall会话 diff --git a/go.mod b/go.mod index 3070930..58f3b3b 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( ) require ( + github.com/csnewman/ffmpeg-go v0.6.0 github.com/glebarez/sqlite v1.11.0 github.com/go-co-op/gocron/v2 v2.16.6 github.com/gorilla/mux v1.8.1 @@ -66,3 +67,5 @@ require ( ) replace github.com/ghettovoice/gosip => github.com/lkmio/gosip v0.0.0-20251016021306-565c7a2fa4f5 + +replace github.com/csnewman/ffmpeg-go => github.com/lkmio/ffmpeg-go v0.0.0-20251129021554-ca32d075a5b7 diff --git a/main.go b/main.go index 35b1b71..a756b0d 100644 --- a/main.go +++ b/main.go @@ -98,6 +98,10 @@ func main() { log.Sugar.Infof("启动http server. addr: %s", httpAddr) go api.StartApiServer(httpAddr) + if api.VideoKeyFrame2JPG != nil { + log.Sugar.Infof("snapshot enabled") + } + err = http.ListenAndServe(":19000", nil) if err != nil { println(err) diff --git a/postproc-57.dll b/postproc-57.dll new file mode 100644 index 0000000..48f65dd Binary files /dev/null and b/postproc-57.dll differ diff --git a/stack/live.go b/stack/live.go index 5020ea9..009c4e3 100644 --- a/stack/live.go +++ b/stack/live.go @@ -59,13 +59,17 @@ func (d *Device) StartStream(inviteType common.InviteType, streamId common.Strea if !ok { log.Sugar.Infof("收流超时 发送bye请求...") CloseStream(streamId, true) + } else if waiting.Data != nil { + if session, ok := waiting.Data.(string); ok { + stream.SessionID = session + } } return ok } - if sync { + if !sync { go wait() - } else if !sync && !wait() { + } else if sync && !wait() { return nil, fmt.Errorf("receiving stream timed out") } diff --git a/swresample-4.dll b/swresample-4.dll new file mode 100644 index 0000000..80bb709 Binary files /dev/null and b/swresample-4.dll differ diff --git a/swscale-7.dll b/swscale-7.dll new file mode 100644 index 0000000..b50591f Binary files /dev/null and b/swscale-7.dll differ