Skip to content

Commit

Permalink
feature: flv preview support
Browse files Browse the repository at this point in the history
  • Loading branch information
alxarno committed Nov 14, 2024
1 parent a741816 commit 0625bd4
Show file tree
Hide file tree
Showing 13 changed files with 235 additions and 35 deletions.
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ linters-settings:
- w http.ResponseWriter
- r *http.Request
- r io.Reader
- w io.Writer
run:
timeout: 5m

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ require (
github.com/u2takey/go-utils v0.3.1 // indirect
github.com/urfave/cli/v2 v2.27.5 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/yutopp/go-amf0 v0.1.0 // indirect
github.com/yutopp/go-flv v0.3.1 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.30.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yutopp/go-amf0 v0.1.0 h1:a3UeBZG7nRF0zfvmPn2iAfNo1RGzUpHz1VyJD2oGrik=
github.com/yutopp/go-amf0 v0.1.0/go.mod h1:QzDOBr9RV6sQh6E5GFEJROZbU0iQKijORBmprkb3FIk=
github.com/yutopp/go-flv v0.3.1 h1:4ILK6OgCJgUNm2WOjaucWM5lUHE0+sLNPdjq3L0Xtjk=
github.com/yutopp/go-flv v0.3.1/go.mod h1:pAlHPSVRMv5aCUKmGOS/dZn/ooTgnc09qOPmiUNMubs=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand Down
135 changes: 135 additions & 0 deletions internal/flv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package internal

import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/draw"
"io"
"os/exec"
"sync"
"time"

"github.com/davidbyttow/govips/v2/vips"
"golang.org/x/image/bmp"
)

var (
ErrPullSnapshot = errors.New("failed to pull snapshot")
ErrImageDecode = errors.New("failed to decode image")
ErrImageEncode = errors.New("failed to encode image")
ErrImageScale = errors.New("failed to scale image")
)

type flvPullSnapshot = func(string, time.Duration, io.Writer)

func getFlvSnapshoter(wg *sync.WaitGroup, errCh chan error, params VideoParams) flvPullSnapshot {
return func(path string, timestamp time.Duration, w io.Writer) {
defer wg.Done()

ctx := context.Background()

if params.timeout > 0 {
var cancel func()
ctx, cancel = context.WithTimeout(context.Background(), params.timeout)
defer cancel()
}

ffmpegTimestamp := durationPrint(timestamp)

seekOptions := []string{"-accurate_seek", "-ss", ffmpegTimestamp}
inputOptions := []string{"-i", path}
outputOptions := []string{"-frames:v", "1", "-c:v", "bmp", "-f", "image2", "pipe:1"}

options := seekOptions
options = append(options, inputOptions...)
options = append(options, outputOptions...)

cmd := exec.CommandContext(ctx, "ffmpeg", options...)
stdErrBuf := bytes.NewBuffer(nil)
cmd.Stdout = w
cmd.Stderr = stdErrBuf

if err := cmd.Run(); err != nil {
errCh <- fmt.Errorf("[%s] %w", stdErrBuf.String(), err)
}
}
}

func combineImagesToPreview(buffers []*bytes.Buffer) ([]byte, error) {
firstImage, _, err := image.Decode(buffers[0])
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrImageDecode, err)
}

height := firstImage.Bounds().Dy()
previewHeight := height * len(buffers)
previewImage := image.NewRGBA(image.Rectangle{
Min: firstImage.Bounds().Min,
Max: image.Point{
X: firstImage.Bounds().Max.X,
Y: previewHeight,
},
})
draw.Draw(previewImage, previewImage.Bounds(), firstImage, image.Point{0, 0}, draw.Src)

for i, v := range buffers[1:] {
img, _, err := image.Decode(v)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrImageDecode, err)
}

s := image.Point{0, (i + 1) * height}
r := image.Rectangle{s, s.Add(img.Bounds().Size())}

draw.Draw(previewImage, r, img, image.Point{0, 0}, draw.Src)
}

buff := bytes.Buffer{}

err = bmp.Encode(&buff, previewImage)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrImageEncode, err)
}

image, err := vips.NewImageFromReader(&buff)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrImageScale, err)
}
defer image.Close()

return downScale(image)
}

func FLVPreviewImage(path string, duration time.Duration, params VideoParams) ([]byte, error) {
images := []*bytes.Buffer{}
parts := 5
step := duration / time.Duration(parts)
waitGroup := new(sync.WaitGroup)
errs := make(chan error, parts)

for v := range parts {
waitGroup.Add(1)

timestamp := step * time.Duration(v)
if timestamp == 0 {
timestamp = time.Second
}

buff := new(bytes.Buffer)
images = append(images, buff)

go getFlvSnapshoter(waitGroup, errs, params)(path, timestamp, buff)
}

waitGroup.Wait()
close(errs)

for err := range errs {
return nil, fmt.Errorf("%w: %w", ErrPullSnapshot, err)
}

return combineImagesToPreview(images)
}
19 changes: 12 additions & 7 deletions internal/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,30 @@ func ImagePreview(path string) (preview.Data, error) {
}
defer image.Close()

scale := 1.0
preview.Resolution = fmt.Sprintf("%dx%d", image.Width(), image.Height())

preview.Data, err = downScale(image)

return preview, err
}

func downScale(image *vips.ImageRef) ([]byte, error) {
scale := 1.0

if image.Width() > maxWidthHeight || image.Height() > maxWidthHeight {
scale = float64(maxWidthHeight) / float64(max(image.Width(), image.Height()))
}

if err = image.Resize(scale, vips.KernelLanczos2); err != nil {
return preview, fmt.Errorf("%w:%w", ErrImageResize, err)
if err := image.Resize(scale, vips.KernelLanczos2); err != nil {
return nil, fmt.Errorf("%w: %w", ErrImageResize, err)
}

ep := vips.NewWebpExportParams()

bytes, _, err := image.ExportWebp(ep)
if err != nil {
return preview, fmt.Errorf("%w:%w", ErrImageExport, err)
return nil, fmt.Errorf("%w: %w", ErrImageExport, err)
}

preview.Data = bytes

return preview, nil
return bytes, nil
}
67 changes: 55 additions & 12 deletions internal/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,20 @@ var (
ErrMetaInfoUnmarshal = errors.New("failed to decode file's meta information")
ErrMetaInfoFramesCountParse = errors.New("failed to parse frames count from meta information")
ErrMetaInfoDurationParse = errors.New("failed to parse duration from meta information")
ErrVideoStreamNotFound = errors.New("video stream not found")
ErrParseFrameRate = errors.New("failed to parse frame rate")
)

type probeFormat struct {
Duration string `json:"duration"`
}

type probeStream struct {
Frames string `json:"nb_frames"` //nolint:tagliatelle
Width int `json:"width"`
Height int `json:"height"`
Frames string `json:"nb_frames"` //nolint:tagliatelle
Width int `json:"width"`
Height int `json:"height"`
AvgFrameRate string `json:"avg_frame_rate"` //nolint:tagliatelle
CodecType string `json:"codec_type"` //nolint:tagliatelle
}

type probeData struct {
Expand All @@ -46,32 +50,63 @@ type VideoParams struct {
timeout time.Duration
}

func getVideoStream(streams []probeStream) *probeStream {
for _, v := range streams {
if v.CodecType == "video" {
return &v
}
}

return nil
}

func probeOutputFrames(a string) (string, float64, time.Duration, error) {
data := probeData{}
resolution := "0x0"
oneMinuteFrames := 3000.0
frames := 3000.0

if err := json.Unmarshal([]byte(a), &data); err != nil {
return resolution, 0, 0, fmt.Errorf("%w:%w", ErrMetaInfoUnmarshal, err)
return resolution, 0, 0, fmt.Errorf("%w: %w", ErrMetaInfoUnmarshal, err)
}

seconds, err := strconv.ParseFloat(data.Format.Duration, 64)
if err != nil {
return resolution, 0, 0, fmt.Errorf("%w: %w", ErrMetaInfoDurationParse, err)
}

videoStream := getVideoStream(data.Streams)
if videoStream == nil {
return resolution, 0, 0, ErrVideoStreamNotFound
}

resolution = fmt.Sprintf("%dx%d", videoStream.Width, videoStream.Height)

// if no frames count in metadata, then just use some default for 1 min video, 24fps
if len(data.Streams) == 0 || data.Streams[0].Frames == "" {
return resolution, oneMinuteFrames, 0, nil
if videoStream.Frames != "" {
frames, err = strconv.ParseFloat(data.Streams[0].Frames, 64)
if err != nil {
return resolution, 0, 0, fmt.Errorf("%w: %w", ErrMetaInfoFramesCountParse, err)
}

return resolution, frames, time.Duration(seconds) * time.Second, nil
}

resolution = fmt.Sprintf("%dx%d", data.Streams[0].Width, data.Streams[0].Height)
if !strings.Contains(videoStream.AvgFrameRate, "/") {
return resolution, frames, 0, nil
}

frames, err := strconv.ParseFloat(data.Streams[0].Frames, 64)
first, err := strconv.Atoi(strings.Split(videoStream.AvgFrameRate, "/")[0])
if err != nil {
return resolution, 0, 0, fmt.Errorf("%w:%w", ErrMetaInfoFramesCountParse, err)
return resolution, frames, 0, ErrParseFrameRate
}

seconds, err := strconv.ParseFloat(data.Format.Duration, 64)
second, err := strconv.Atoi(strings.Split(videoStream.AvgFrameRate, "/")[1])
if err != nil {
return resolution, 0, 0, fmt.Errorf("%w:%w", ErrMetaInfoDurationParse, err)
return resolution, frames, 0, ErrParseFrameRate
}

frames = seconds * float64(first/second)

return resolution, frames, time.Duration(seconds) * time.Second, nil
}

Expand All @@ -91,6 +126,14 @@ func VideoPreview(path string, params VideoParams) (preview.Data, error) {
preview.Resolution = resolution
preview.Duration = duration

if path[len(path)-3:] == "flv" {
if preview.Data, err = FLVPreviewImage(path, duration, params); err != nil {
return preview, err
}

return preview, nil
}

//nolint:gomnd,mnd
previewFrames := []int64{
int64(frames * 0.2),
Expand Down
10 changes: 10 additions & 0 deletions internal/video_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ func TestPreviewVideo(t *testing.T) {
hash := sha256.Sum256(preview.Data)
assert.Equal(t, "913e1f20eb400f3a13aa043005204ef53e0883c122086b96d94a2b6279ec008e", hex.EncodeToString(hash[:]))
}

func TestPreviewVideoFLV(t *testing.T) {
t.Parallel()

preview, err := VideoPreview("../test/video/sample_960x400_ocean_with_audio.flv", VideoParams{timeout: time.Minute})
require.NoError(t, err)
assert.Len(t, preview.Data, 11590)
hash := sha256.Sum256(preview.Data)
assert.Equal(t, "b7583f7f39807c1ef4636423281f38e6a67d979e3bf2aa0a1a53fb35470c31d1", hex.EncodeToString(hash[:]))
}
2 changes: 1 addition & 1 deletion pkg/bytesutil/writer_count.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (counter *WriterCounter) Write(buf []byte) (int, error) {
}

if err != nil {
return n, fmt.Errorf("%w:%w", ErrWrite, err)
return n, fmt.Errorf("%w: %w", ErrWrite, err)
}

return n, nil
Expand Down
2 changes: 1 addition & 1 deletion pkg/httputil/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
rw.size += n

if err != nil {
return n, fmt.Errorf("%w:%w", ErrWrite, err)
return n, fmt.Errorf("%w: %w", ErrWrite, err)
}

return n, nil
Expand Down
2 changes: 1 addition & 1 deletion pkg/index/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func (ib *indexBuilder) loadFiles(ctx context.Context) error {
return nil
}

return fmt.Errorf("%w:%w", ErrSemaphoreAcquire, err)
return fmt.Errorf("%w: %w", ErrSemaphoreAcquire, err)
}

waitGroup.Add(1)
Expand Down
2 changes: 1 addition & 1 deletion pkg/index/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func (fp *fileProcessor) run(file FileMeta, id string) {

preview, err := fp.preview.Pull(meta.Path)
if err != nil {
slog.Error(fmt.Errorf("%w:%w", ErrPreviewPull, err).Error())
slog.Error(fmt.Errorf("%w: %w", ErrPreviewPull, err).Error())

return
}
Expand Down
Loading

0 comments on commit 0625bd4

Please sign in to comment.