From df3252ca6ee12bafafb5aaed298193142ff93248 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Fri, 14 Aug 2020 00:01:18 +0200 Subject: Add /upload command to upload image/video (jpeg, png, webm and mp4) Requires ffmpeg and ffprobe (which is part of ffmpeg) programs to be installed on the system --- ui/command-processor.go | 1 + ui/commands.go | 226 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) (limited to 'ui') diff --git a/ui/command-processor.go b/ui/command-processor.go index 514d67b..21155fa 100644 --- a/ui/command-processor.go +++ b/ui/command-processor.go @@ -133,6 +133,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { "redact": cmdRedact, "react": cmdReact, "download": cmdDownload, + "upload": cmdUpload, "open": cmdOpen, "copy": cmdCopy, "sendevent": cmdSendEvent, diff --git a/ui/commands.go b/ui/commands.go index 9d38396..0cbf2b0 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -17,11 +17,16 @@ package ui import ( + "bytes" "encoding/json" "fmt" + "image" "io" + "io/ioutil" "math" "os" + "os/exec" + "path/filepath" "regexp" "runtime" dbg "runtime/debug" @@ -42,6 +47,7 @@ import ( "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" + "maunium.net/go/gomuks/matrix/muksevt" ) func cmdMe(cmd *Command) { @@ -175,6 +181,226 @@ func cmdDownload(cmd *Command) { cmd.Room.StartSelecting(SelectDownload, strings.Join(cmd.Args, " ")) } +type VideoInfo struct { + width int + height int + duration int +} + +func videoGetInfo(filePath string) (VideoInfo, error) { + cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_entries", "format=duration", "--", filePath) + var buffer bytes.Buffer + cmd.Stdout = &buffer + if cmd.Run() != nil { + return VideoInfo{0, 0, 0}, errors.New("Failed to extract video dimensions using ffprobe") + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(buffer.Bytes(), &jsonData); err != nil { + return VideoInfo{0, 0, 0}, errors.New("Failed to parse ffprobe response as json") + } + + // TODO: Check if fields do not exist or a are wrong type??? + format := jsonData["format"].(map[string]interface{}) + durationStr := format["duration"].(string) + duration, err := strconv.ParseFloat(durationStr, 64) + if err != nil { + return VideoInfo{0, 0, 0}, errors.New("Failed to parse ffprobe response as json (duration is not a string)") + } + + streams := jsonData["streams"].([]interface{}) + firstStream := streams[0].(map[string]interface{}) + + return VideoInfo{int(firstStream["width"].(float64)), int(firstStream["height"].(float64)), int(duration * 1000)}, nil +} + +func videoGetThumbnail(filePath string) ([]byte, error) { + cmd := exec.Command("ffmpeg", "-i", filePath, "-vframes", "1", "-f", "singlejpeg", "-") + var buffer bytes.Buffer + cmd.Stdout = &buffer + if cmd.Run() != nil { + return nil, errors.New("Failed to extract thumbnail from video") + } + return buffer.Bytes(), nil +} + +func uploadVideo(cmd *Command, filePath string, fileName string, fileSize int, videoFormat string) { + videoInfo, err := videoGetInfo(filePath) + if err != nil { + cmd.Reply("Failed to get video info, error: %v", err) + return + } + + thumbnailData, err := videoGetThumbnail(filePath) + if err != nil { + cmd.Reply("Failed to extract thumbnail for video") + return + } + + imageMetadata, _, err := image.DecodeConfig(bytes.NewReader(thumbnailData)) + if err != nil { + cmd.Reply("Failed to decode thumbnail image") + return + } + + thumbnailResp, err := cmd.Matrix.Client().UploadBytesWithName(thumbnailData, "image/jpeg", "thumbnail.jpg") + if err != nil { + cmd.Reply("Failed to upload thumbnail: %v", err) + return + } + + fileData, err := ioutil.ReadFile(filePath) + if err != nil { + cmd.Reply("Failed to read file, error: %v", err) + return + } + + videoMimeType := fmt.Sprintf("video/%s", videoFormat) + resp, err := cmd.Matrix.Client().UploadBytesWithName(fileData, videoMimeType, fileName) + if err != nil { + cmd.Reply("Failed to upload video: %v", err) + return + } + + txnID := cmd.Matrix.Client().TxnID() + content := event.MessageEventContent{ + Body: fileName, + Info: &event.FileInfo{ + Duration: videoInfo.duration, + Width: videoInfo.width, + Height: videoInfo.height, + MimeType: videoMimeType, + Size: fileSize, + ThumbnailURL: id.ContentURIString(fmt.Sprintf("mxc://%s/%s", thumbnailResp.ContentURI.Homeserver, thumbnailResp.ContentURI.FileID)), + ThumbnailInfo: &event.FileInfo{ + Width: imageMetadata.Width, + Height: imageMetadata.Height, + MimeType: "image/jpeg", + Size: len(thumbnailData), + }, + }, + MsgType: event.MsgVideo, + URL: id.ContentURIString(fmt.Sprintf("mxc://%s/%s", resp.ContentURI.Homeserver, resp.ContentURI.FileID)), + } + + _, err = cmd.Matrix.SendEvent(&muksevt.Event{ + Event: &event.Event{ + Content: event.Content{Parsed: &content}, + ID: id.EventID(txnID), + RoomID: cmd.Room.MxRoom().ID, + Timestamp: time.Now().UnixNano() / 1e6, + Sender: cmd.Matrix.Client().UserID, + Type: event.EventMessage, + Unsigned: event.Unsigned{TransactionID: txnID}, + }, + }) + + if err != nil { + cmd.Reply("Failed to upload video: %v", err) + return + } +} + +func uploadImage(cmd *Command, filePath string, fileName string) { + fileData, err := ioutil.ReadFile(filePath) + if err != nil { + cmd.Reply("Failed to read file, error: %v", err) + return + } + fileSize := len(fileData) + + imageMetadata, _, err := image.DecodeConfig(bytes.NewReader(fileData)) + if err != nil { + cmd.Reply("Failed to decode image") + return + } + + var imageType string + // TODO: Detect all types matrix supports + if fileSize >= 4 && fileData[0] == 0xFF && fileData[1] == 0xD8 && fileData[fileSize-2] == 0xFF && fileData[fileSize-1] == 0xD9 { + imageType = "jpeg" + } else if fileSize >= 8 && string(fileData[0:8]) == "\211PNG\r\n\032\n" { + imageType = "png" + } + + if imageType == "" { + cmd.Reply("Only jpeg, png, webm and mp4 files are supported") + return + } + + imageMimeType := fmt.Sprintf("image/%s", imageType) + + resp, err := cmd.Matrix.Client().UploadBytesWithName(fileData, imageMimeType, fileName) + if err != nil { + cmd.Reply("Failed to upload image: %v", err) + return + } + + txnID := cmd.Matrix.Client().TxnID() + //cmd.Reply("Uploaded image: %v", fmt.Sprintf("mxc://%s/%s", resp.ContentURI.Homeserver, resp.ContentURI.FileID)) + content := event.MessageEventContent{ + Body: fileName, + Info: &event.FileInfo{ + Width: imageMetadata.Width, + Height: imageMetadata.Height, + MimeType: imageMimeType, + Size: int(fileSize), + // TODO: "xyz.amorgan.blurhash: " https://github.com/buckket/go-blurhash + }, + MsgType: event.MsgImage, + URL: id.ContentURIString(fmt.Sprintf("mxc://%s/%s", resp.ContentURI.Homeserver, resp.ContentURI.FileID)), + } + + _, err = cmd.Matrix.SendEvent(&muksevt.Event{ + Event: &event.Event{ + Content: event.Content{Parsed: &content}, + ID: id.EventID(txnID), + RoomID: cmd.Room.MxRoom().ID, + Timestamp: time.Now().UnixNano() / 1e6, + Sender: cmd.Matrix.Client().UserID, + Type: event.EventMessage, + Unsigned: event.Unsigned{TransactionID: txnID}, + }, + }) + + if err != nil { + cmd.Reply("Failed to upload image: %v", err) + return + } +} + +func cmdUpload(cmd *Command) { + filePath := cmd.RawArgs + fileName := filepath.Base(filePath) + fileExt := filepath.Ext(fileName) + + // TODO: This is not safe. Instead open the file and get file size blabla? + stat, err := os.Stat(filePath) + if err != nil { + cmd.Reply("Failed to get file stats for %s, error: %v", filePath, err) + return + } + fileSize := stat.Size() + + if fileSize >= 40*1024*1024 { // 40mb + cmd.Reply("Can't upload a file larger than 40mb. The file you are trying to upload is %fmb", float64(fileSize)/1024.0/1024.0) + return + } + + var videoFormat string + if fileExt == ".webm" { + videoFormat = "webm" + } else if fileExt == ".mp4" { + videoFormat = "mp4" + } + + if videoFormat != "" { + go uploadVideo(cmd, filePath, fileName, int(fileSize), videoFormat) + } else { + go uploadImage(cmd, filePath, fileName) + } +} + func cmdOpen(cmd *Command) { cmd.Room.StartSelecting(SelectOpen, strings.Join(cmd.Args, " ")) } -- cgit v1.2.3