fix: 还未生成截图, 访问应答404问题; 截图缩放到960*540大小;

This commit is contained in:
ydajiang
2025-12-12 14:35:14 +08:00
parent 140d427494
commit df4d5e8913
6 changed files with 148 additions and 12 deletions

View File

@@ -259,6 +259,22 @@ func (api *ApiServer) registerStatisticsHandler(actionName, path string, handler
api.actionNames[path] = actionName
}
type SnapshotFilter struct {
}
func (f *SnapshotFilter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if "" != r.URL.Query().Get("preview_snapshot") {
t := r.URL.Query().Get("t")
ok := DefaultSnapshotManager.Get(t, 2*time.Second)
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
}
http.FileServer(http.Dir("./snapshot")).ServeHTTP(w, r)
}
func StartApiServer(addr string) {
apiServer.router.HandleFunc("/api/v1/hook/on_play", common.WithJsonParams(apiServer.OnPlay, &PlayDoneParams{}))
apiServer.router.HandleFunc("/api/v1/hook/on_play_done", common.WithJsonParams(apiServer.OnPlayDone, &PlayDoneParams{}))
@@ -268,7 +284,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.router.HandleFunc("/api/v1/hook/on_snapshot", apiServer.OnSnapshot)
apiServer.registerStatisticsHandler("开始预览", "/api/v1/stream/start", withVerify(common.WithFormDataParams(apiServer.OnStreamStart, InviteParams{}))) // 实时预览
apiServer.registerStatisticsHandler("停止预览", "/api/v1/stream/stop", withVerify(common.WithFormDataParams(apiServer.OnCloseLiveStream, InviteParams{}))) // 关闭实时预览
@@ -411,7 +427,8 @@ func StartApiServer(addr string) {
})
// 映射snapshot目录
apiServer.router.PathPrefix("/snapshot/").Handler(http.StripPrefix("/snapshot/", http.FileServer(http.Dir("./snapshot"))))
filter := SnapshotFilter{}
apiServer.router.PathPrefix("/snapshot/").Handler(http.StripPrefix("/snapshot/", &filter))
// 前端路由
htmlRoot := "./html/"

View File

@@ -8,6 +8,7 @@ import (
"gb-cms/stack"
"github.com/csnewman/ffmpeg-go"
"github.com/lkmio/avformat/utils"
"io"
"net/http"
"os"
"path"
@@ -234,11 +235,28 @@ func (api *ApiServer) OnStarted(_ http.ResponseWriter, _ *http.Request) {
}
}
func (api *ApiServer) OnSnapshot(v *SnapshotParams, writer http.ResponseWriter, request *http.Request) {
func (api *ApiServer) OnSnapshot(w http.ResponseWriter, r *http.Request) {
if VideoKeyFrame2JPG == nil {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024*2)
data, err := io.ReadAll(r.Body)
if err != nil {
if err.Error() == "http: request body too large" {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
} else {
http.Error(w, "read body failed", http.StatusInternalServerError)
}
return
}
v := &SnapshotParams{}
v.Stream = common.StreamID(r.Header.Get("stream"))
v.Session = r.Header.Get("session")
v.Codec = r.Header.Get("codec")
v.KeyFrameData = data
var codecId ffmpeg.AVCodecID
switch strings.ToLower(v.Codec) {
case "h264":
@@ -250,8 +268,8 @@ func (api *ApiServer) OnSnapshot(v *SnapshotParams, writer http.ResponseWriter,
return
}
jpgPath := GetSnapshotPath(v.Stream, v.Session)
err := os.MkdirAll(path.Dir(jpgPath), 0755)
jpgPath := GetSnapshotPath(v.Stream)
err = os.MkdirAll(path.Dir(jpgPath), 0755)
if err != nil {
log.Sugar.Errorf("创建目录失败 err: %s", err.Error())
return
@@ -261,16 +279,18 @@ func (api *ApiServer) OnSnapshot(v *SnapshotParams, writer http.ResponseWriter,
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 {
} else if err = dao.Channel.SetSnapshotPath(v.Stream.DeviceID(), v.Stream.ChannelID(), jpgPath+"?t="+v.Session); err != nil {
// 数据库更新通道的最新截图
log.Sugar.Errorf("更新通道最新截图失败 err: %s", err.Error())
} else {
DefaultSnapshotManager.Put(v.Session, true)
}
}
func GetSnapshotPath(streamID common.StreamID, sessionID string) string {
func GetSnapshotPath(streamID common.StreamID) string {
if VideoKeyFrame2JPG == nil {
return ""
}
return path.Join("./snapshot/", string(streamID), sessionID+".jpg")
return path.Join("./snapshot/", string(streamID)+".jpg")
}

View File

@@ -116,7 +116,7 @@ func (api *ApiServer) DoStartStream(v *InviteParams, w http.ResponseWriter, r *h
RecordStartAt: "",
RelaySize: 0,
SMSID: "",
SnapURL: GetSnapshotPath(stream.StreamID, stream.SessionID),
SnapURL: GetSnapshotPath(stream.StreamID) + "?preview_snapshot=1&t=" + stream.SessionID,
SourceAudioCodecName: "",
SourceAudioSampleRate: 0,
SourceVideoCodecName: "",

View File

@@ -3,8 +3,10 @@
package api
import (
"bytes"
"fmt"
"github.com/csnewman/ffmpeg-go"
"github.com/disintegration/imaging"
"os"
"unsafe"
)
@@ -111,9 +113,41 @@ func saveFrameAsJPEG(frame *ffmpeg.AVFrame, filename string) error {
return fmt.Errorf("接收包失败 %v", err)
}
// 写入文件
if _, err := file.Write(unsafe.Slice((*byte)(pkt.Data()), pkt.Size())); err != nil {
return fmt.Errorf("写入文件失败 %v", err)
// 获取编码后的 JPEG 原始字节数据
jpegBytes := unsafe.Slice((*byte)(pkt.Data()), pkt.Size())
// 判断是否需要缩放
targetW, targetH := 960, 540
if (frame.Width() > frame.Height()) && frame.Width() > targetW || frame.Height() > targetH {
// [路径 A: 需要缩放]
// 1. 将 JPEG 字节流解码为 Go Image 对象
img, err := imaging.Decode(bytes.NewReader(jpegBytes))
if err != nil {
return fmt.Errorf("Go解码图片失败: %v", err)
}
// 2. 使用 imaging 库进行缩放 (Fit 会保持比例,适应 960x540 的框)
// 使用 Lanczos 算法保证缩放后的清晰度
dstImg := imaging.Fit(img, targetW, targetH, imaging.Lanczos)
// 3. 保存缩放后的图片到文件
if err := imaging.Save(dstImg, filename, imaging.JPEGQuality(80)); err != nil {
return fmt.Errorf("保存缩放图片失败: %v", err)
}
} else {
// [路径 B: 不需要缩放]
// 直接将 FFmpeg 编码好的字节写入文件,效率最高
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("创建文件失败 %v", err)
}
defer file.Close()
if _, err := file.Write(jpegBytes); err != nil {
return fmt.Errorf("写入文件失败 %v", err)
}
}
return nil

63
api/snapshot_async.go Normal file
View File

@@ -0,0 +1,63 @@
package api
import (
"container/list"
"context"
"sync"
"time"
)
const (
SnapshotQueueMaxSize = 128
)
var (
DefaultSnapshotManager = &SnapshotManager{
snapshots: make(map[string][]func(), SnapshotQueueMaxSize*1.5),
keys: list.New(),
}
)
type SnapshotManager struct {
lock sync.RWMutex
snapshots map[string][]func()
keys *list.List
}
func (s *SnapshotManager) Get(id string, timeout time.Duration) bool {
s.lock.Lock()
if _, ok := s.snapshots[id]; ok {
s.lock.Unlock()
return true
}
background, cancel := context.WithCancel(context.Background())
s.snapshots[id] = append(s.snapshots[id], cancel)
// 超时未获取到截图, 则认为不存在
after := time.After(timeout)
s.lock.Unlock()
select {
case <-after:
return false
case <-background.Done():
return true
}
}
func (s *SnapshotManager) Put(id string, ok bool) {
s.lock.Lock()
defer s.lock.Unlock()
// 通知获取截图的goroutine, 截图已准备好
for _, cancel := range s.snapshots[id] {
cancel()
}
// 超出最大队列长度, 则删除最早的截图
for s.keys.Len() > SnapshotQueueMaxSize {
oldElement := s.keys.Front()
s.keys.Remove(oldElement)
delete(s.snapshots, oldElement.Value.(string))
}
}

2
go.mod
View File

@@ -43,6 +43,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/term v0.21.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -54,6 +55,7 @@ require (
require (
github.com/csnewman/ffmpeg-go v0.6.0
github.com/disintegration/imaging v1.6.2
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.16.6
github.com/gorilla/mux v1.8.1