202 lines
6.9 KiB
Go
202 lines
6.9 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"frame"
|
||
|
|
|
||
|
|
"maunium.net/go/mautrix"
|
||
|
|
"maunium.net/go/mautrix/event"
|
||
|
|
"maunium.net/go/mautrix/id"
|
||
|
|
)
|
||
|
|
|
||
|
|
// matrixStore posts clips into Matrix rooms as m.video events. The clips and
|
||
|
|
// detections rooms carry full JSON metadata in an m.text reply posted directly
|
||
|
|
// beneath each video event; the view room carries only a one-line human caption
|
||
|
|
// so clips can be browsed without scrolling through JSON.
|
||
|
|
type matrixStore struct {
|
||
|
|
client *mautrix.Client
|
||
|
|
clipsRoom id.RoomID
|
||
|
|
detectionsRoom id.RoomID
|
||
|
|
viewRoom id.RoomID
|
||
|
|
}
|
||
|
|
|
||
|
|
// newMatrixStore builds an authenticated Matrix client and verifies the
|
||
|
|
// access token against the homeserver so that misconfiguration fails fast
|
||
|
|
// at startup instead of on the first clip.
|
||
|
|
func newMatrixStore(homeserver, userID, token, clipsRoom, detectionsRoom, viewRoom string) (*matrixStore, error) {
|
||
|
|
cli, err := mautrix.NewClient(homeserver, id.UserID(userID), token)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("matrix client: %w", err)
|
||
|
|
}
|
||
|
|
cli.StateStore = mautrix.NewMemoryStateStore()
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if _, err := cli.Whoami(ctx); err != nil {
|
||
|
|
return nil, fmt.Errorf("matrix auth check: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, room := range []string{clipsRoom, detectionsRoom, viewRoom} {
|
||
|
|
if !strings.HasPrefix(room, "!") {
|
||
|
|
return nil, fmt.Errorf("invalid room ID %q (must start with '!')", room)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return &matrixStore{
|
||
|
|
client: cli,
|
||
|
|
clipsRoom: id.RoomID(clipsRoom),
|
||
|
|
detectionsRoom: id.RoomID(detectionsRoom),
|
||
|
|
viewRoom: id.RoomID(viewRoom),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// uploadClip uploads a clip's MP4 to the homeserver media repository once and
|
||
|
|
// returns the mxc:// URI so every target room can reference the same media.
|
||
|
|
func (ms *matrixStore) uploadClip(ctx context.Context, filename string, mp4 []byte) (id.ContentURI, error) {
|
||
|
|
resp, err := ms.client.UploadBytesWithName(ctx, mp4, "video/mp4", filename)
|
||
|
|
if err != nil {
|
||
|
|
return id.ContentURI{}, fmt.Errorf("upload clip media: %w", err)
|
||
|
|
}
|
||
|
|
return resp.ContentURI, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// sendClipToRoom posts a clip's already-uploaded media as an m.video event to
|
||
|
|
// roomID, then sends the clip's metadata as an m.text reply beneath it. It
|
||
|
|
// returns the video event ID.
|
||
|
|
func (ms *matrixStore) sendClipToRoom(ctx context.Context, roomID id.RoomID, clp frame.Clip, filename string, mxc id.ContentURI, mp4Len int, fps float64) (id.EventID, error) {
|
||
|
|
videoResp, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, videoContent(filename, mxc, mp4Len))
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("send clip video event: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
meta, err := buildClipMetadata(&clp, fps)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("build metadata: %w", err)
|
||
|
|
}
|
||
|
|
if _, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, metadataContent(videoResp.EventID, meta)); err != nil {
|
||
|
|
return "", fmt.Errorf("send metadata reply: %w", err)
|
||
|
|
}
|
||
|
|
return videoResp.EventID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// videoContent renders the m.video event content for an uploaded clip, using
|
||
|
|
// label as the human-visible body text (the file name, or a short caption).
|
||
|
|
func videoContent(label string, mxc id.ContentURI, mp4Len int) *event.MessageEventContent {
|
||
|
|
return &event.MessageEventContent{
|
||
|
|
MsgType: event.MsgVideo,
|
||
|
|
Body: label,
|
||
|
|
URL: mxc.CUString(),
|
||
|
|
Info: &event.FileInfo{
|
||
|
|
MimeType: "video/mp4",
|
||
|
|
Size: mp4Len,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sendClipView posts a clip's already-uploaded media to roomID as an m.video
|
||
|
|
// event with a one-line human caption instead of the JSON metadata reply, so
|
||
|
|
// the room reads as a clean clip feed.
|
||
|
|
func (ms *matrixStore) sendClipView(ctx context.Context, roomID id.RoomID, clp frame.Clip, mxc id.ContentURI, mp4Len int, fps float64) (id.EventID, error) {
|
||
|
|
resp, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, videoContent(viewCaption(&clp, fps), mxc, mp4Len))
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("send clip view event: %w", err)
|
||
|
|
}
|
||
|
|
return resp.EventID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// viewCaption renders the minimal, human-readable caption used for the view
|
||
|
|
// room. It carries just enough to identify a clip without any JSON.
|
||
|
|
func viewCaption(clp *frame.Clip, fps float64) string {
|
||
|
|
return fmt.Sprintf("%s · %d frames · %.1f fps · motion %d%%",
|
||
|
|
clp.SourceData, len(clp.PixelMats), fps, clp.GetHighestMotion())
|
||
|
|
}
|
||
|
|
|
||
|
|
// metadataContent renders the m.text reply that carries a clip's metadata
|
||
|
|
// beneath its video event.
|
||
|
|
func metadataContent(eventID id.EventID, meta string) *event.MessageEventContent {
|
||
|
|
return &event.MessageEventContent{
|
||
|
|
MsgType: event.MsgText,
|
||
|
|
Body: meta,
|
||
|
|
RelatesTo: &event.RelatesTo{
|
||
|
|
InReplyTo: &event.InReplyTo{
|
||
|
|
EventID: eventID,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// routeTargets decides where a clip should be stored. Clips with no motion
|
||
|
|
// are dropped (empty result). Motion clips land in the clips room and, when
|
||
|
|
// they also carry classifier detections, additionally in the detections room.
|
||
|
|
func routeTargets(clp *frame.Clip, clipsRoom, detectionsRoom id.RoomID) []id.RoomID {
|
||
|
|
if clp.GetHighestMotion() == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
targets := []id.RoomID{clipsRoom}
|
||
|
|
if hasDetections(clp) {
|
||
|
|
targets = append(targets, detectionsRoom)
|
||
|
|
}
|
||
|
|
return targets
|
||
|
|
}
|
||
|
|
|
||
|
|
// hasDetections reports whether any frame in the clip carries at least one
|
||
|
|
// classifier detection.
|
||
|
|
func hasDetections(clp *frame.Clip) bool {
|
||
|
|
for _, frameDets := range clp.Detections {
|
||
|
|
for _, dets := range frameDets {
|
||
|
|
if len(dets) > 0 {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
type clipJSON struct {
|
||
|
|
SourceData string `json:"source_data"`
|
||
|
|
Width uint `json:"width"`
|
||
|
|
Height uint `json:"height"`
|
||
|
|
Channels int `json:"channels"`
|
||
|
|
Type int `json:"gocv_image_type"`
|
||
|
|
FrameCount int `json:"frame_count"`
|
||
|
|
FPS float64 `json:"fps"`
|
||
|
|
Timestamps []uint64 `json:"timestamps"`
|
||
|
|
Guids []string `json:"guids"`
|
||
|
|
Detections []map[string][]frame.Detection `json:"detections"`
|
||
|
|
MotionPercent int `json:"motion_percent"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildClipMetadata renders the clip's stored metadata as the text body of
|
||
|
|
// the reply post that sits beneath the clip's video event.
|
||
|
|
func buildClipMetadata(clp *frame.Clip, fps float64) (string, error) {
|
||
|
|
guids := make([]string, len(clp.Guids))
|
||
|
|
for i, g := range clp.Guids {
|
||
|
|
guids[i] = fmt.Sprintf("%x", g)
|
||
|
|
}
|
||
|
|
|
||
|
|
meta := clipJSON{
|
||
|
|
SourceData: clp.SourceData,
|
||
|
|
Width: clp.Width,
|
||
|
|
Height: clp.Height,
|
||
|
|
Channels: clp.Channels,
|
||
|
|
Type: int(clp.Types),
|
||
|
|
FrameCount: len(clp.PixelMats),
|
||
|
|
FPS: fps,
|
||
|
|
Timestamps: clp.Timestamps,
|
||
|
|
Guids: guids,
|
||
|
|
Detections: clp.Detections,
|
||
|
|
MotionPercent: clp.GetHighestMotion(),
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("marshal: %w", err)
|
||
|
|
}
|
||
|
|
return string(data), nil
|
||
|
|
}
|