From ccd16b82fa6ab8d385f27fccdc789f24a0ffe081 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 23 Nov 2025 17:05:17 +0530 Subject: [PATCH] Add support for video previews --- go.mod | 1 + go.sum | 2 + kittens/choose_files/ffmpeg.go | 182 ++++++++++++++++++++++++++++++++ kittens/choose_files/preview.go | 10 +- 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 kittens/choose_files/ffmpeg.go diff --git a/go.mod b/go.mod index 95126a5a4..591493488 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 github.com/kovidgoyal/go-shm v1.0.0 diff --git a/go.sum b/go.sum index 62fcb6c2e..a0b41fb52 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= diff --git a/kittens/choose_files/ffmpeg.go b/kittens/choose_files/ffmpeg.go new file mode 100644 index 000000000..8f92f6a4a --- /dev/null +++ b/kittens/choose_files/ffmpeg.go @@ -0,0 +1,182 @@ +package choose_files + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/hako/durafmt" + "github.com/kovidgoyal/imaging/magick" + "github.com/kovidgoyal/kitty/tools/icons" + "github.com/kovidgoyal/kitty/tools/utils/humanize" + "github.com/kovidgoyal/kitty/tools/utils/images" + "golang.org/x/sys/unix" +) + +var _ = fmt.Print + +// ffmpeg -y -i bath.mp4 -t 5 -vf "fps=10,scale=320:-1:flags=lanczos" -c:v +// libwebp -lossless 0 -compression_level 0 -q:v 75 -loop 0 output_quality.webp + +const FFMPEG_METADATA_KEY = "ffmpeg-metadata.json" + +type ffmpeg_renderer int + +var ( + video_width = 320 + video_fps = 10 + video_duration = 5 + video_encoding_quality = 75 +) + +func ffmpeg_thumbnail_cmd(path, outpath string) *exec.Cmd { + return exec.Command( + "ffmpeg", "-loglevel", "fatal", "-y", "-i", path, "-t", fmt.Sprintf("%d", video_duration), + "-vf", fmt.Sprintf("fps=%d,scale=%d:-1:flags=lanczos", video_fps, video_width), + "-c:v", "libwebp", "-lossless", "0", "-compression_level", "0", "-q:v", + fmt.Sprintf("%d", video_encoding_quality), "-loop", "0", "-f", "webp", outpath, + ) +} + +func ffmpeg_thumbnail(path, tempath string, wg *sync.WaitGroup) (ans *images.ImageData, err error) { + defer wg.Done() + cmd := ffmpeg_thumbnail_cmd(path, tempath) + cmd.Stdin = nil + cmd.SysProcAttr = &unix.SysProcAttr{Setsid: true} + var stderr bytes.Buffer + cmd.Stdout = nil + cmd.Stderr = &stderr + if err = cmd.Run(); err != nil { + return ans, fmt.Errorf("failed to use ffmpeg to render video from %s with error: %w and stderr: %s", path, err, stderr.String()) + } + ans, err = images.OpenImageFromPath(tempath) + return +} + +type FFMpegFormat struct { + Start_time string `json:"start_time"` + Duration string `json:"duration"` + Tags map[string]any `json:"tags"` +} + +type FFMpegStream struct { + Codec_type string `json:"codec_type"` + Width int `json:"width"` + Height int `json:"height"` +} + +type FFMpegMetadata struct { + Streams []FFMpegStream `json:"streams"` + Format FFMpegFormat `json:"format"` +} + +func ffmpeg_metadata_cmd(path string) *exec.Cmd { + return exec.Command( + "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path, + ) +} + +func ffmpeg_metadata(path string, wg *sync.WaitGroup) (ans FFMpegMetadata, err error) { + defer wg.Done() + cmd := ffmpeg_metadata_cmd(path) + cmd.Stdin = nil + cmd.SysProcAttr = &unix.SysProcAttr{Setsid: true} + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err = cmd.Run(); err != nil { + return ans, fmt.Errorf("failed to use ffprobe to read metadata from %s with error: %w and stderr: %s", path, err, stderr.String()) + } + if err = json.Unmarshal(stdout.Bytes(), &ans); err != nil { + return ans, fmt.Errorf("could not decode JSON response from ffprobe for %s: %w", path, err) + } + return +} + +func (c ffmpeg_renderer) Render(path string) (m map[string][]byte, mi metadata, img *images.ImageData, err error) { + wg := sync.WaitGroup{} + tempfile, err := os.CreateTemp(magick.TempDirInRAMIfPossible(), "kitty-choose-files-*.webp") + if err != nil { + return nil, mi, nil, err + } + defer func() { _ = os.Remove(tempfile.Name()); tempfile.Close() }() + var metadata FFMpegMetadata + var metadata_error error + wg.Add(1) + go func() { metadata, metadata_error = ffmpeg_metadata(path, &wg) }() + wg.Add(1) + go func() { img, err = ffmpeg_thumbnail(path, tempfile.Name(), &wg) }() + wg.Wait() + if metadata_error != nil { + return nil, mi, nil, metadata_error + } + var ip ImagePreviewRenderer + if m, mi, img, err = ip.Render(tempfile.Name()); err != nil { + return + } + mi.custom = &metadata + + return +} + +func (c ffmpeg_renderer) Unmarshall(m map[string]string) (any, error) { + data, err := os.ReadFile(m[FFMPEG_METADATA_KEY]) + if err != nil { + return nil, err + } + var ans FFMpegMetadata + if err = json.Unmarshal(data, &ans); err != nil { + return nil, err + } + return &ans, nil +} + +func (c ffmpeg_renderer) ShowMetadata(h *Handler, s ShowData) (offset int) { + w := func(text string, center bool) { + if s.height > offset { + offset += h.render_wrapped_text_in_region(text, s.x, s.y+offset, s.width, s.height-offset, center) + } + } + ext := filepath.Ext(s.abspath) + text := fmt.Sprintf("%s: %s", ext, humanize.Bytes(uint64(s.metadata.Size()))) + icon := icons.IconForPath(s.abspath) + w(icon+" "+text, true) + r := s.custom_metadata.custom.(*FFMpegMetadata) + if d, perr := strconv.ParseFloat(r.Format.Duration, 64); perr == nil { + duration := time.Duration(d * float64(time.Second)) + w("Duration: "+durafmt.Parse(duration).String(), false) + } + var width, height int + for _, s := range r.Streams { + if s.Width > 0 && s.Height > 0 { + width, height = s.Width, s.Height + break + } + } + if width*height > 0 { + w(fmt.Sprintf("Resolution: %dx%d", width, height), false) + } + return +} + +func (c ffmpeg_renderer) String() string { + return "FFMpeg" +} + +func NewFFMpegPreview( + abspath string, metadata fs.FileInfo, opts Settings, WakeupMainThread func() bool, +) Preview { + c := ffmpeg_renderer(0) + if ans, err := NewImagePreview(abspath, metadata, opts, WakeupMainThread, c); err == nil { + return ans + } else { + return NewErrorPreview(err) + } +} diff --git a/kittens/choose_files/preview.go b/kittens/choose_files/preview.go index e2e7b4f0c..0fb48f4d2 100644 --- a/kittens/choose_files/preview.go +++ b/kittens/choose_files/preview.go @@ -342,15 +342,19 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr } return ans } - if strings.HasPrefix(mt, "image/") { + switch { + case strings.HasPrefix(mt, "image/"): var r ImagePreviewRenderer if ans, err := NewImagePreview(abspath, s, pm.settings, pm.WakeupMainThread, r); err == nil { return ans } else { return NewErrorPreview(err) } - } - if IsSupportedByCalibre(abspath) { + + case strings.HasPrefix(mt, "video/"): + return NewFFMpegPreview(abspath, s, pm.settings, pm.WakeupMainThread) + + case IsSupportedByCalibre(abspath): return NewCalibrePreview(abspath, s, pm.settings, pm.WakeupMainThread) } return NewFileMetadataPreview(abspath, s)