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 }