summaryrefslogtreecommitdiff
path: root/internal/thumbnail/generator.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/thumbnail/generator.go')
-rw-r--r--internal/thumbnail/generator.go111
1 files changed, 111 insertions, 0 deletions
diff --git a/internal/thumbnail/generator.go b/internal/thumbnail/generator.go
new file mode 100644
index 0000000..20aa391
--- /dev/null
+++ b/internal/thumbnail/generator.go
@@ -0,0 +1,111 @@
+package thumbnail
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Generator struct {
+ ffmpegPath string
+}
+
+func NewGenerator() *Generator {
+ return &Generator{
+ ffmpegPath: "ffmpeg",
+ }
+}
+
+func (g *Generator) Generate(ctx context.Context, videoPath string) (string, error) {
+ duration, err := g.probeVideoDuration(ctx, videoPath)
+ if err != nil {
+ return "", fmt.Errorf("probe video duration: %w", err)
+ }
+
+ timestamp := duration * time.Duration(thumbnailPercent) / 100
+ thumbnailPath := g.getThumbnailPath(videoPath)
+
+ if err := g.extractFrame(ctx, videoPath, thumbnailPath, timestamp); err != nil {
+ return "", fmt.Errorf("extract frame: %w", err)
+ }
+
+ return thumbnailPath, nil
+}
+
+func (g *Generator) probeVideoDuration(ctx context.Context, videoPath string) (time.Duration, error) {
+ args := []string{
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ videoPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ffprobe", args...)
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ seconds, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
+ if err != nil {
+ return 0, err
+ }
+
+ return time.Duration(seconds * float64(time.Second)), nil
+}
+
+func (g *Generator) extractFrame(ctx context.Context, videoPath, thumbnailPath string, timestamp time.Duration) error {
+ thumbnailDir := filepath.Dir(thumbnailPath)
+ if err := ensureDir(thumbnailDir); err != nil {
+ return err
+ }
+
+ timestampSec := timestamp.Seconds()
+ timestampStr := fmt.Sprintf("%.3f", timestampSec)
+
+ args := []string{
+ "-ss", timestampStr,
+ "-i", videoPath,
+ "-vframes", "1",
+ "-vf", fmt.Sprintf("scale=%d:%d", thumbnailWidth, thumbnailHeight),
+ "-y",
+ thumbnailPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, g.ffmpegPath, args...)
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (g *Generator) getThumbnailPath(videoPath string) string {
+ dir := filepath.Dir(videoPath)
+ thumbnailDir := filepath.Join(dir, thumbnailDir)
+
+ filename := filepath.Base(videoPath)
+ ext := filepath.Ext(filename)
+ name := strings.TrimSuffix(filename, ext)
+
+ return filepath.Join(thumbnailDir, name+"."+thumbnailFormat)
+}
+
+func ensureDir(path string) error {
+ if _, err := exec.LookPath("mkdir"); err == nil {
+ cmd := exec.Command("mkdir", "-p", path)
+ return cmd.Run()
+ }
+
+ return fmt.Errorf("mkdir not found")
+}