Files
2026-09-09 21:44:05 -05:00

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
}