mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-26 18:22:09 +02:00
Add support for video previews
This commit is contained in:
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/dlclark/regexp2 v1.11.5
|
github.com/dlclark/regexp2 v1.11.5
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/google/uuid v1.6.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/dbus v0.0.0-20250519011319-e811c41c0bc1
|
||||||
github.com/kovidgoyal/go-parallel v1.1.1
|
github.com/kovidgoyal/go-parallel v1.1.1
|
||||||
github.com/kovidgoyal/go-shm v1.0.0
|
github.com/kovidgoyal/go-shm v1.0.0
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
|||||||
182
kittens/choose_files/ffmpeg.go
Normal file
182
kittens/choose_files/ffmpeg.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,15 +342,19 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr
|
|||||||
}
|
}
|
||||||
return ans
|
return ans
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(mt, "image/") {
|
switch {
|
||||||
|
case strings.HasPrefix(mt, "image/"):
|
||||||
var r ImagePreviewRenderer
|
var r ImagePreviewRenderer
|
||||||
if ans, err := NewImagePreview(abspath, s, pm.settings, pm.WakeupMainThread, r); err == nil {
|
if ans, err := NewImagePreview(abspath, s, pm.settings, pm.WakeupMainThread, r); err == nil {
|
||||||
return ans
|
return ans
|
||||||
} else {
|
} else {
|
||||||
return NewErrorPreview(err)
|
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 NewCalibrePreview(abspath, s, pm.settings, pm.WakeupMainThread)
|
||||||
}
|
}
|
||||||
return NewFileMetadataPreview(abspath, s)
|
return NewFileMetadataPreview(abspath, s)
|
||||||
|
|||||||
Reference in New Issue
Block a user