From e3157f0fb175805cb977a066f61ccb9b3ee03975 Mon Sep 17 00:00:00 2001 From: Eduardo Milani Date: Thu, 19 Feb 2026 09:07:08 -0300 Subject: [PATCH] fix(video): add duration, width and height metadata to VideoMessage Videos sent via /chat/send/video were missing Seconds, Width, and Height fields in the VideoMessage proto, causing recipients to see 0-second duration and inability to play on WhatsApp Desktop and mobile clients. This change: - Adds getVideoMetadata() helper that uses ffprobe to extract video duration and dimensions from the file data - Sets Seconds, Width, and Height on the VideoMessage proto - Accepts optional Seconds/Width/Height in the API payload, which take priority over auto-detected values - Gracefully degrades to current behavior if ffprobe is unavailable --- handlers.go | 19 ++++++++++++++ helpers.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/handlers.go b/handlers.go index 285a7a2f..18fd7948 100644 --- a/handlers.go +++ b/handlers.go @@ -1484,6 +1484,9 @@ func (s *server) SendVideo() http.HandlerFunc { Id string JPEGThumbnail []byte MimeType string + Seconds uint32 `json:"Seconds,omitempty"` + Width uint32 `json:"Width,omitempty"` + Height uint32 `json:"Height,omitempty"` ContextInfo waE2E.ContextInfo QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } @@ -1571,6 +1574,19 @@ func (s *server) SendVideo() http.HandlerFunc { return } + // Extract video metadata (duration, dimensions) via ffprobe. + // If caller provided Seconds/Width/Height in payload, those take priority. + videoMeta := getVideoMetadata(filedata) + if t.Seconds > 0 { + videoMeta.DurationSeconds = t.Seconds + } + if t.Width > 0 { + videoMeta.Width = t.Width + } + if t.Height > 0 { + videoMeta.Height = t.Height + } + msg := &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ Caption: proto.String(t.Caption), URL: proto.String(uploaded.URL), @@ -1586,6 +1602,9 @@ func (s *server) SendVideo() http.HandlerFunc { FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(filedata))), JPEGThumbnail: t.JPEGThumbnail, + Seconds: proto.Uint32(videoMeta.DurationSeconds), + Width: proto.Uint32(videoMeta.Width), + Height: proto.Uint32(videoMeta.Height), }} if t.ContextInfo.StanzaID != nil { diff --git a/helpers.go b/helpers.go index c1d8fabc..da9fc4b1 100644 --- a/helpers.go +++ b/helpers.go @@ -23,6 +23,7 @@ import ( "os/exec" "regexp" "runtime/debug" + "strconv" "strings" "sync" @@ -772,6 +773,81 @@ func runFFmpegConversion(input []byte, inputExt string, ffmpegArgs func(inPath, return os.ReadFile(outPath) } +// VideoMetadata holds extracted video properties (duration, dimensions). +type VideoMetadata struct { + DurationSeconds uint32 + Width uint32 + Height uint32 +} + +// getVideoMetadata uses ffprobe to extract duration, width, and height from video data. +// Returns zero values if extraction fails (graceful degradation). +func getVideoMetadata(filedata []byte) VideoMetadata { + meta := VideoMetadata{} + + cmd := exec.Command("ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + "-select_streams", "v:0", + "-i", "-", + ) + cmd.Stdin = bytes.NewReader(filedata) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + log.Warn().Err(err).Str("stderr", stderr.String()).Msg("getVideoMetadata: ffprobe failed") + return meta + } + + var probeResult struct { + Streams []struct { + Width int `json:"width"` + Height int `json:"height"` + Duration string `json:"duration"` + } `json:"streams"` + Format struct { + Duration string `json:"duration"` + } `json:"format"` + } + + if err := json.Unmarshal(stdout.Bytes(), &probeResult); err != nil { + log.Warn().Err(err).Msg("getVideoMetadata: failed to parse ffprobe output") + return meta + } + + var durationStr string + if len(probeResult.Streams) > 0 { + stream := probeResult.Streams[0] + meta.Width = uint32(stream.Width) + meta.Height = uint32(stream.Height) + durationStr = stream.Duration + } + + // Fallback to format-level duration if stream duration was not available + if durationStr == "" && probeResult.Format.Duration != "" { + durationStr = probeResult.Format.Duration + } + + if durationStr != "" { + if dur, err := strconv.ParseFloat(durationStr, 64); err == nil && dur > 0 { + meta.DurationSeconds = uint32(dur + 0.5) + } + } + + log.Debug(). + Uint32("duration", meta.DurationSeconds). + Uint32("width", meta.Width). + Uint32("height", meta.Height). + Msg("getVideoMetadata: extracted video metadata") + + return meta +} + func convertVideoStickerToWebP(input []byte) ([]byte, error) { return runFFmpegConversion(input, ".mp4", func(inPath, outPath string) []string { return []string{