Skip to content

Commit 481d409

Browse files
feat: add video preview support (#1178)
1 parent 01f8cbc commit 481d409

File tree

4 files changed

+193
-25
lines changed

4 files changed

+193
-25
lines changed

src/internal/common/predefined_variable.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ var (
6464

6565
var (
6666
UnsupportedPreviewFormats = []string{".pdf", ".torrent"}
67+
ImageExtensions = map[string]bool{
68+
".jpg": true,
69+
".jpeg": true,
70+
".png": true,
71+
".gif": true,
72+
".bmp": true,
73+
".tiff": true,
74+
".svg": true,
75+
".webp": true,
76+
".ico": true,
77+
}
78+
VideoExtensions = map[string]bool{
79+
".mkv": true,
80+
".mp4": true,
81+
".mov": true,
82+
".avi": true,
83+
".flv": true,
84+
".webm": true,
85+
".wmv": true,
86+
".m4v": true,
87+
".mpeg": true,
88+
".3gp": true,
89+
".ogv": true,
90+
}
6791
)
6892

6993
// No dependencies

src/internal/model.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,10 +771,12 @@ func (panel *filePanel) applyTargetFileCursor() {
771771
// Close superfile application. Cd into the current dir if CdOnQuit on and save
772772
// the path in state direcotory
773773
func (m *model) quitSuperfile(cdOnQuit bool) {
774-
// close exiftool session
774+
// Resource cleanup
775775
if common.Config.Metadata && et != nil {
776776
et.Close()
777777
}
778+
m.fileModel.filePreview.CleanUp()
779+
778780
// cd on quit
779781
currentDir := m.fileModel.filePanels[m.filePanelFocusIndex].location
780782
variable.SetLastDir(currentDir)

src/internal/ui/preview/model.go

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,26 @@ import (
3131
)
3232

3333
type Model struct {
34-
open bool
35-
width int
36-
height int
37-
location string
38-
content string
39-
imagePreviewer *filepreview.ImagePreviewer
40-
batCmd string
34+
open bool
35+
width int
36+
height int
37+
location string
38+
content string
39+
imagePreviewer *filepreview.ImagePreviewer
40+
batCmd string
41+
thumbnailGenerator *filepreview.ThumbnailGenerator
4142
}
4243

4344
func New() Model {
45+
generator, err := filepreview.NewThumbnailGenerator()
46+
if err != nil {
47+
slog.Error("Could not NewThumbnailGenerator object", "error", err)
48+
}
49+
4450
return Model{
45-
open: common.Config.DefaultOpenFilePreview,
46-
imagePreviewer: filepreview.NewImagePreviewer(),
51+
open: common.Config.DefaultOpenFilePreview,
52+
imagePreviewer: filepreview.NewImagePreviewer(),
53+
thumbnailGenerator: generator,
4754
// TODO: This is an IO operation, move to async ?
4855
batCmd: checkBatCmd(),
4956
}
@@ -112,6 +119,15 @@ func (m *Model) ToggleOpen() {
112119
m.open = !m.open
113120
}
114121

122+
func (m *Model) CleanUp() {
123+
if m.thumbnailGenerator != nil {
124+
err := m.thumbnailGenerator.CleanUp()
125+
if err != nil {
126+
slog.Error("Error While cleaning up TempDirectory", "Error:", err)
127+
}
128+
}
129+
}
130+
115131
func renderFileInfoError(r *rendering.Renderer, err error) string {
116132
slog.Error("Error get file info", "error", err)
117133
return r.Render()
@@ -164,7 +180,8 @@ func renderDirectoryPreview(r *rendering.Renderer, itemPath string, previewHeigh
164180
}
165181

166182
func (m *Model) renderImagePreview(box lipgloss.Style, itemPath string, previewWidth,
167-
previewHeight int, sideAreaWidth int) string {
183+
previewHeight int, sideAreaWidth int,
184+
) string {
168185
if !m.open {
169186
return box.Render("\n --- Preview panel is closed ---")
170187
}
@@ -197,7 +214,8 @@ func (m *Model) renderImagePreview(box lipgloss.Style, itemPath string, previewW
197214
}
198215

199216
func (m *Model) renderTextPreview(r *rendering.Renderer, box lipgloss.Style, itemPath string,
200-
previewWidth, previewHeight int) string {
217+
previewWidth, previewHeight int,
218+
) string {
201219
format := lexers.Match(filepath.Base(itemPath))
202220
if format == nil {
203221
isText, err := common.IsTextFile(itemPath)
@@ -274,6 +292,18 @@ func (m *Model) RenderWithPath(itemPath string, fullModelWidth int) string {
274292
return renderDirectoryPreview(r, itemPath, previewHeight) + clearCmd
275293
}
276294

295+
if isVideoFile(itemPath) {
296+
if m.thumbnailGenerator == nil {
297+
return renderUnsupportedFormat(box) + clearCmd
298+
}
299+
thumbnailPath, err := m.thumbnailGenerator.GetThumbnailOrGenerate(itemPath)
300+
if err != nil {
301+
slog.Error("Error generating thumbnail", "error", err)
302+
return renderUnsupportedFormat(box) + clearCmd
303+
}
304+
return m.renderImagePreview(box, thumbnailPath, previewWidth, previewHeight, fullModelWidth-previewWidth+1)
305+
}
306+
277307
if isImageFile(itemPath) {
278308
return m.renderImagePreview(box, itemPath, previewWidth, previewHeight, fullModelWidth-previewWidth+1)
279309
}
@@ -333,18 +363,9 @@ func checkBatCmd() string {
333363
}
334364

335365
func isImageFile(filename string) bool {
336-
imageExtensions := map[string]bool{
337-
".jpg": true,
338-
".jpeg": true,
339-
".png": true,
340-
".gif": true,
341-
".bmp": true,
342-
".tiff": true,
343-
".svg": true,
344-
".webp": true,
345-
".ico": true,
346-
}
366+
return common.ImageExtensions[strings.ToLower(filepath.Ext(filename))]
367+
}
347368

348-
ext := strings.ToLower(filepath.Ext(filename))
349-
return imageExtensions[ext]
369+
func isVideoFile(filename string) bool {
370+
return common.VideoExtensions[strings.ToLower(filepath.Ext(filename))]
350371
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package filepreview
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"sync"
10+
"time"
11+
)
12+
13+
const (
14+
maxFileSize = "104857600" // 100MB limit
15+
outputExt = ".jpg"
16+
generationTimeout = 30 * time.Second
17+
)
18+
19+
type ThumbnailGenerator struct {
20+
// This is a cache. Key -> Video file path, Value -> Thumbnail file path
21+
// TODO: We can potentially make it persisitent, preventing generation
22+
// of thumbnail on every launch or superfile
23+
tempFilesCache map[string]string
24+
tempDirectory string
25+
mu sync.Mutex
26+
}
27+
28+
func NewThumbnailGenerator() (*ThumbnailGenerator, error) {
29+
if !isFFmpegInstalled() {
30+
return nil, errors.New("ffmpeg is not installed")
31+
}
32+
33+
tmp, err := os.MkdirTemp("", "superfiles-*")
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
thumbnailGenerator := &ThumbnailGenerator{
39+
tempFilesCache: make(map[string]string),
40+
tempDirectory: tmp,
41+
}
42+
43+
return thumbnailGenerator, nil
44+
}
45+
46+
func (g *ThumbnailGenerator) GetThumbnailOrGenerate(path string) (string, error) {
47+
g.mu.Lock()
48+
file, ok := g.tempFilesCache[path]
49+
g.mu.Unlock()
50+
51+
if ok {
52+
_, err := os.Stat(file)
53+
if err == nil {
54+
return file, nil
55+
}
56+
57+
g.mu.Lock()
58+
delete(g.tempFilesCache, path)
59+
g.mu.Unlock()
60+
}
61+
62+
generatedThumbnailPath, err := g.generateThumbnail(path)
63+
if err != nil {
64+
return "", err
65+
}
66+
67+
g.mu.Lock()
68+
g.tempFilesCache[path] = generatedThumbnailPath
69+
g.mu.Unlock()
70+
71+
return generatedThumbnailPath, nil
72+
}
73+
74+
func (g *ThumbnailGenerator) generateThumbnail(inputPath string) (string, error) {
75+
fileExt := filepath.Ext(inputPath)
76+
filename := filepath.Base(inputPath)
77+
baseName := filename[:len(filename)-len(fileExt)]
78+
79+
outputFile, err := os.CreateTemp(g.tempDirectory, "*-"+baseName+outputExt)
80+
if err != nil {
81+
return "", err
82+
}
83+
outputFilePath := outputFile.Name()
84+
outputFile.Close()
85+
86+
ctx, cancel := context.WithTimeout(context.Background(), generationTimeout)
87+
defer cancel()
88+
89+
// ffmpeg -v warning -t 60 -hwaccel auto -an -sn -dn -skip_frame nokey -i input.mkv -vf scale='min(1024,iw)':'min(720,ih)':force_original_aspect_ratio=decrease:flags=fast_bilinear -vf "thumbnail" -frames:v 1 -y thumb.jpg
90+
ffmpeg := exec.CommandContext(ctx, "ffmpeg",
91+
"-v", "warning", // set log level to warning
92+
"-an", // disable Audio stream
93+
"-sn", // disable Subtitle stream
94+
"-dn", // disable data stream
95+
"-t", "180", // process maximum 180s of the video (the first 3 min)
96+
"-hwaccel", "auto", // Use Hardware Acceleration if available
97+
"-skip_frame", "nokey", // skip non-key frames
98+
"-i", inputPath, // set input file
99+
"-vf", "thumbnail", // use ffmpeg default thumbnail filter
100+
"-frames:v", "1", // output only one frame (one image)
101+
"-f", "image2", // set format to image2
102+
"-fs", maxFileSize, // limit the max file size to match image previewer limit
103+
"-y", outputFilePath, // set the outputFile and overwrite it without confirmation if already exists
104+
)
105+
106+
err = ffmpeg.Run()
107+
if err != nil {
108+
return "", err
109+
}
110+
111+
return outputFilePath, nil
112+
}
113+
114+
func (g *ThumbnailGenerator) CleanUp() error {
115+
return os.RemoveAll(g.tempDirectory)
116+
}
117+
118+
func isFFmpegInstalled() bool {
119+
_, err := exec.LookPath("ffmpeg")
120+
return err == nil
121+
}

0 commit comments

Comments
 (0)