From 1dcfe2cd85f2816bc45cc119c5cf9085a9c2ecfa Mon Sep 17 00:00:00 2001 From: k1nho Date: Tue, 11 Jun 2024 23:08:51 -0400 Subject: [PATCH] fix: get video duration from ffmpeg --- app.go | 12 +- ffmpegbuilder/ffmpegbuilder.go | 15 ++ ffmpegbuilder/query.go | 23 ++- ffmpegbuilder/validate.go | 14 ++ frontend/src/App.svelte | 36 +++- frontend/src/VideoLayout.svelte | 38 ++-- frontend/src/components/SearchList.svelte | 8 +- frontend/src/lib/dnd.ts | 4 +- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + frontend/wailsjs/go/models.ts | 4 + internal/video/video.go | 20 ++- video.go | 210 +++++++++++++++++++--- 13 files changed, 336 insertions(+), 60 deletions(-) diff --git a/app.go b/app.go index fa5a4df..1916d3f 100644 --- a/app.go +++ b/app.go @@ -207,7 +207,17 @@ func (a *App) ReadProjectWorkspace() ([]Video, error) { if !video.IsValidExtension(filepath.Ext(project.Name())) { continue } - projectFiles = append(projectFiles, Video{Name: strings.Split(project.Name(), ".")[0], Extension: filepath.Ext(project.Name()), FilePath: a.config.ProjectDir}) + + duration, err := getVideoDuration(video.ProcessingOpts{ + Filename: strings.Split(project.Name(), ".")[0], + VideoFormat: filepath.Ext(project.Name()), + InputPath: a.config.ProjectDir, + }) + if err != nil { + wruntime.LogError(a.ctx, fmt.Sprintf("could not check video duration: %s", err.Error())) + continue + } + projectFiles = append(projectFiles, Video{Name: strings.Split(project.Name(), ".")[0], Extension: filepath.Ext(project.Name()), FilePath: a.config.ProjectDir, Duration: duration}) } } diff --git a/ffmpegbuilder/ffmpegbuilder.go b/ffmpegbuilder/ffmpegbuilder.go index b0d221e..1cf7bc4 100644 --- a/ffmpegbuilder/ffmpegbuilder.go +++ b/ffmpegbuilder/ffmpegbuilder.go @@ -63,6 +63,8 @@ type OutputParams struct { // MovFlags: -movflags in ffmpeg, mov, mp4, and ismv support fragmentation. The metadata about all packets is stored in one location, // but it can be moved at the start for better playback (+faststart) MovFlags string + // NullOutput: -f null - in ffmpeg (used to check info only) + NullOutput string } func NewDefaultFFmpegBuilder() *FFmpegBuilder { @@ -113,11 +115,21 @@ func (f *FFmpegBuilder) WithStatsPeriod(statsPeriod string) *FFmpegBuilder { return f } +func (f *FFmpegBuilder) WithVerbose(verbose string) *FFmpegBuilder { + f.PreInputParams.VerboseMode = "" + return f +} + func (f *FFmpegBuilder) WithCodec(codec string) *FFmpegBuilder { f.OutputParams.Codec = codec return f } +func (f *FFmpegBuilder) WithNullOutput() *FFmpegBuilder { + f.OutputParams.NullOutput = "-f null -" + return f +} + func (f *FFmpegBuilder) WithVideoCodec(videoCodec string) *FFmpegBuilder { f.OutputParams.VideoCodec = videoCodec return f @@ -258,6 +270,9 @@ func (f *FFmpegBuilder) BuildQuery() (string, error) { } // Append output parameters + if f.OutputParams.NullOutput != "" { + cmd.WriteString(fmt.Sprintf("%s ", f.OutputParams.NullOutput)) + } if f.OutputParams.Duration != 0 { cmd.WriteString(fmt.Sprintf("-t %.4f ", f.OutputParams.Duration)) } diff --git a/ffmpegbuilder/query.go b/ffmpegbuilder/query.go index e129315..a02a833 100644 --- a/ffmpegbuilder/query.go +++ b/ffmpegbuilder/query.go @@ -5,14 +5,31 @@ import ( "github.com/k1nho/gahara/internal/video" ) +func CheckVideoDuration(userOpts video.ProcessingOpts) (string, error) { + input := GetFullInputPath(userOpts) + + query, err := NewDefaultFFmpegBuilder().WithInputs(input). + WithNullOutput().WithVerbose("").BuildQuery() + if err != nil { + return "", err + } + return query, nil +} + // CreateProxyFileQuery: creates a proxy file for a video func CreateProxyFileQuery(userOpts video.ProcessingOpts, format string) (string, error) { input := GetFullInputPath(userOpts) userOpts.VideoFormat = format output := GetFullOutputPath(userOpts) - query, err := NewDefaultFFmpegBuilder().WithInputs(input).WithCodec("copy"). - WithOutputs(output).BuildQuery() + querybuilder := NewDefaultFFmpegBuilder().WithInputs(input).WithCodec("copy"). + WithOutputs(output) + + if err := querybuilder.validateProxyFileCreationQuery(); err != nil { + return "", err + } + + query, err := querybuilder.BuildQuery() if err != nil { return "", err } @@ -20,7 +37,7 @@ func CreateProxyFileQuery(userOpts video.ProcessingOpts, format string) (string, return query, nil } -// GenerateThumbnailQuery: generate a thumbnail from a video +// CreateThumbnailQuery: generates a thumbnail taking the 1 frame of a video func CreateThumbnailQuery(userOpts video.ProcessingOpts, format string) (string, error) { input := GetFullInputPath(userOpts) userOpts.VideoFormat = format diff --git a/ffmpegbuilder/validate.go b/ffmpegbuilder/validate.go index c61716a..9a20265 100644 --- a/ffmpegbuilder/validate.go +++ b/ffmpegbuilder/validate.go @@ -38,3 +38,17 @@ func (f *FFmpegBuilder) validateLosslessCutQuery() error { } return nil } + +func (f *FFmpegBuilder) validateProxyFileCreationQuery() error { + if len(f.Inputs) != 1 { + return fmt.Errorf("no input stream(s) provided") + } + if len(f.Outputs) != 1 { + return fmt.Errorf("no output stream(s) provided") + } + + if f.OutputParams.Codec != "copy" { + return fmt.Errorf("codec must be copy. No re-encoding needed for proxy") + } + return nil +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 68e2c90..62305fa 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,20 +1,50 @@ diff --git a/frontend/src/components/SearchList.svelte b/frontend/src/components/SearchList.svelte index 73f15f8..b0990c0 100644 --- a/frontend/src/components/SearchList.svelte +++ b/frontend/src/components/SearchList.svelte @@ -12,8 +12,7 @@ } = toolingStore; const { addVideoToTrack } = trackStore; const { searchFiles } = videoFiles; - const { source, setVideoSrc, setCurrentTime, getDuration, viewVideo } = - videoStore; + const { source, setVideoSrc, setCurrentTime, viewVideo } = videoStore; let searchTerm = ""; let searchIdx = -1; @@ -44,14 +43,11 @@ if (searchIdx >= 0 && searchIdx < searchList.length) { viewVideo(searchList[searchIdx]); - const videoDuration = getDuration(); - if (videoDuration === 0) return; - InsertInterval( $source, searchList[searchIdx].name, 0, - videoDuration, + searchList[searchIdx].duration, $videoNodePos, ) .then((tVideo) => { diff --git a/frontend/src/lib/dnd.ts b/frontend/src/lib/dnd.ts index 67267db..ef064f1 100644 --- a/frontend/src/lib/dnd.ts +++ b/frontend/src/lib/dnd.ts @@ -1,5 +1,5 @@ import { draggedVideo, trackStore, videoStore, toolingStore } from "../stores"; -import type { video, main } from "../../wailsjs/go/models"; +import type { main } from "../../wailsjs/go/models"; import { InsertInterval } from "../../wailsjs/go/main/App"; export function draggable(node: HTMLDivElement, data: main.Video) { @@ -72,7 +72,7 @@ export function dropzone(node: HTMLDivElement, opts) { videoID, draggedVideo.value().name, 0, - videoStore.getDuration(), + draggedVideo.value().duration, 0, ) .then((tVideo) => { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 1f82af4..7b56d7a 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -36,6 +36,8 @@ export function GetTrackDuration():Promise; export function InsertInterval(arg1:string,arg2:string,arg3:number,arg4:number,arg5:number):Promise; +export function LoadProjectFiles():Promise>; + export function LoadTimeline():Promise; export function OpenFile(arg1:string):Promise; @@ -50,6 +52,8 @@ export function RenameVideoNode(arg1:number,arg2:string):Promise; export function ResetTimeline():Promise; +export function SaveProjectFiles(arg1:Array):Promise; + export function SaveTimeline():Promise; export function SetDefaultAppMenu():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 481235a..7e46ff3 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -66,6 +66,10 @@ export function InsertInterval(arg1, arg2, arg3, arg4, arg5) { return window['go']['main']['App']['InsertInterval'](arg1, arg2, arg3, arg4, arg5); } +export function LoadProjectFiles() { + return window['go']['main']['App']['LoadProjectFiles'](); +} + export function LoadTimeline() { return window['go']['main']['App']['LoadTimeline'](); } @@ -94,6 +98,10 @@ export function ResetTimeline() { return window['go']['main']['App']['ResetTimeline'](); } +export function SaveProjectFiles(arg1) { + return window['go']['main']['App']['SaveProjectFiles'](arg1); +} + export function SaveTimeline() { return window['go']['main']['App']['SaveTimeline'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 62efbb0..04e6fd7 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,9 +1,11 @@ export namespace main { export class Video { + id: string; name: string; extension: string; filepath: string; + duration: number; static createFrom(source: any = {}) { return new Video(source); @@ -11,9 +13,11 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; this.name = source["name"]; this.extension = source["extension"]; this.filepath = source["filepath"]; + this.duration = source["duration"]; } } diff --git a/internal/video/video.go b/internal/video/video.go index 21b8d3f..66851a3 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -70,6 +70,8 @@ const ( EVT_INTERVAL_CUT = "intervalCut" // EVT_SLICE_CUT: slice cut event EVT_SLICE_CUT = "evt_slice_cut" + // EVT_DURATION_EXTRACTED: duration extracted from ffmpeg execution + EVT_DURATION_EXTRACTED = "evt_duration_extracted" // EVT_ENCODING_PROGRESS: encoding progress event EVT_ENCODING_PROGRESS = "evt_encoding_progress" // EVT_EXPORT_MSG: message for exporting a video events @@ -124,6 +126,12 @@ const ( PRESET_MEDIUM = "medium" // PRESET_FAST: fast preset provides faster encoding speed at the tradeoff of better compression PRESET_FAST = "fast" + // OUT_TIME_US: out_time_us term to monitor in ffmpeg execution + OBV_OUT_TIME_US = "out_time_us" + // OUT_TIME: out_time term to monitor in ffmpeg execution + OBV_OUT_TIME = "out_time" + // Duration: duration term to monitor in ffmpeg execution + OBV_DURATION = "Duration" ) type VideoNode struct { @@ -342,9 +350,6 @@ func FormatTime(seconds float64) string { } func (p *ProcessingOpts) ValidateRequiredFields(queryType string) error { - if p.OutputPath == "" { - return fmt.Errorf("output path was not provided") - } if p.Filename == "" { return fmt.Errorf("filename was not provided") } @@ -359,12 +364,21 @@ func (p *ProcessingOpts) ValidateRequiredFields(queryType string) error { switch queryType { case QUERY_FILTERGRAPH: + if p.OutputPath == "" { + return fmt.Errorf("output path was not provided") + } if !p.isCodecCompatible() { return fmt.Errorf("codec is not compatible with %s format", p.VideoFormat) } case QUERY_LOSSLESS_CUT: + if p.OutputPath == "" { + return fmt.Errorf("output path was not provided") + } case QUERY_CREATE_PROXY_FILE, QUERY_CREATE_THUMBNAIL: + if p.OutputPath == "" { + return fmt.Errorf("output path was not provided") + } if p.InputPath == "" { return fmt.Errorf("input path was not provided") } diff --git a/video.go b/video.go index b86a2a3..13f7661 100644 --- a/video.go +++ b/video.go @@ -21,12 +21,16 @@ import ( ) type Video struct { + // ID: the unique identifier of the video + ID string `json:"id"` // Name: the name of the video file (includes extension) Name string `json:"name"` // Extension: the container type of the video (mp4, avi, etc) Extension string `json:"extension"` // FilePath: the absolute path of the video FilePath string `json:"filepath"` + // Duration: the duration of the video in seconds + Duration float64 `json:"duration"` } type Interval struct { @@ -41,6 +45,30 @@ type VideoProcessingResult struct { Message string `json:"message"` } +type MonitoringOpts struct { + terms map[string]bool +} + +func NewVideo(name string, extension string, filepath string, duration float64) *Video { + return &Video{ + ID: strings.Replace(uuid.New().String(), "-", "", -1), + Name: name, + Extension: extension, + FilePath: filepath, + Duration: duration, + } +} + +func NewMonitoringOpts(observeParams ...string) *MonitoringOpts { + terms := make(map[string]bool) + for _, word := range observeParams { + terms[word] = true + } + return &MonitoringOpts{ + terms: terms, + } +} + func NewVideoProcessingResult(id string, name string, status string, msg string) *VideoProcessingResult { if id == "" { id = strings.Replace(uuid.New().String(), "-", "", -1) @@ -81,9 +109,20 @@ func (a *App) createProxyFile(inputFilePath string) { // check that a proxy has not already been created for the file _, err = os.Stat(pathProxyFile) if os.IsNotExist(err) { - cmd := video.CreateProxyFileCMD(inputFilePath, pathProxyFile) - wruntime.EventsEmit(a.ctx, video.EVT_PROXY_PIPELINE_MSG, name) - err := cmd.Run() + pfile := NewVideo(name, filepath.Ext(proxyFile), a.config.ProjectDir, 0) + + cancelDurationListener := wruntime.EventsOnce(a.ctx, video.EVT_DURATION_EXTRACTED, func(duration ...interface{}) { + pfile.Duration = duration[0].(float64) + wruntime.EventsEmit(a.ctx, video.EVT_PROXY_FILE_CREATED, pfile) + }) + defer cancelDurationListener() + + err := a.FFmpegQuery(video.QUERY_CREATE_PROXY_FILE, video.ProcessingOpts{ + Filename: name, + VideoFormat: fmt.Sprintf(".%s", ext), + InputPath: filepath.Dir(inputFilePath), + OutputPath: a.config.ProjectDir, + }) if err != nil { wruntime.LogError(a.ctx, fmt.Sprintf("could not create the proxy file for %s: %s", inputFilePath, err.Error())) wruntime.EventsEmit(a.ctx, video.EVT_PROXY_ERROR_MSG, fmt.Sprintf("failed to import %s", fileName)) @@ -91,14 +130,6 @@ func (a *App) createProxyFile(inputFilePath string) { } wruntime.LogInfo(a.ctx, fmt.Sprintf("proxy file created: %s", fileName)) - wruntime.EventsEmit(a.ctx, video.EVT_PROXY_FILE_CREATED, - Video{Name: name, FilePath: a.config.ProjectDir, Extension: filepath.Ext(proxyFile)}) - - // Once the proxy file is created, generate a thumbnail - err = a.GenerateThumbnail(pathProxyFile) - if err != nil { - wruntime.LogError(a.ctx, fmt.Sprintf("could not generate thumbnail for proxy file %s: %s", inputFilePath, err.Error())) - } return } else if err != nil { wruntime.LogError(a.ctx, fmt.Sprintf("file finding error: %s", err.Error())) @@ -190,6 +221,21 @@ func (a *App) GetProjectThumbnail(projectName string) (string, error) { } +func (a *App) SaveProjectFiles(projectFiles []Video) error { + data, err := json.MarshalIndent(projectFiles, "", " ") + if err != nil { + return err + } + + err = os.WriteFile(path.Join(a.config.ProjectDir, "metadata.json"), data, 0644) + if err != nil { + return err + } + + wruntime.LogInfo(a.ctx, "project files have been saved") + return nil +} + // SaveTimeline: save project timeline into the project filesystem func (a *App) SaveTimeline() error { if a.Timeline.VideoNodes == nil && len(a.Timeline.VideoNodes) <= 0 { @@ -204,7 +250,7 @@ func (a *App) SaveTimeline() error { if err != nil { return err } - wruntime.LogInfo(a.ctx, fmt.Sprintf("%s: Timeline has been saved", time.Now().String())) + wruntime.LogInfo(a.ctx, fmt.Sprintf("%s: timeline has been saved", time.Now().String())) return nil } @@ -213,7 +259,7 @@ func (a *App) LoadTimeline() (video.Timeline, error) { var timeline video.Timeline timelinePath := path.Join(a.config.ProjectDir, "timeline.json") if _, err := os.Stat(timelinePath); err != nil { - return timeline, fmt.Errorf("No timeline found for this project") + return timeline, fmt.Errorf("no timeline found for this project") } bytes, err := os.ReadFile(timelinePath) @@ -233,10 +279,40 @@ func (a *App) LoadTimeline() (video.Timeline, error) { return timeline, fmt.Errorf("empty timeline") } - wruntime.LogInfo(a.ctx, "timeline file has been found!") + wruntime.LogInfo(a.ctx, "timeline has been loaded!") return a.GetTimeline(), nil } +// LoadTimeline: retrieves saved project files, if any, from filesystem +func (a *App) LoadProjectFiles() ([]Video, error) { + var videoFiles []Video + metadataPath := path.Join(a.config.ProjectDir, "metadata.json") + if _, err := os.Stat(metadataPath); err != nil { + return videoFiles, fmt.Errorf("No video files found for this project") + } + + bytes, err := os.ReadFile(metadataPath) + if err != nil { + wruntime.LogError(a.ctx, "could not read the video files metadata file") + return videoFiles, fmt.Errorf("could not read timeline file") + } + + err = json.Unmarshal(bytes, &videoFiles) + if err != nil { + wruntime.LogError(a.ctx, "could not unmarshal the files") + return videoFiles, err + } + + if len(videoFiles) == 0 { + wruntime.LogInfo(a.ctx, "empty video files") + return videoFiles, fmt.Errorf("empty video files") + } + + wruntime.LogInfo(a.ctx, "video files loaded") + return videoFiles, nil + +} + // GetTimeline: returns the video timeline which is composed of video nodes func (a *App) GetTimeline() video.Timeline { return a.Timeline @@ -348,7 +424,7 @@ func (a *App) queryFiltergraph(userOpts video.ProcessingOpts) error { if err != nil { return err } - err = a.executeFFmpegQuery(query, true) + err = a.executeFFmpegQuery(query, NewMonitoringOpts(video.OBV_OUT_TIME_US)) if err != nil { return err } @@ -385,7 +461,7 @@ func (a *App) queryLosslessCut(userOpts video.ProcessingOpts) error { msgChannel <- VideoProcessingResult{ID: vNode.ID, Status: Failed, Message: err.Error()} return } - err = a.executeFFmpegQuery(query, false) + err = a.executeFFmpegQuery(query, nil) if err != nil { msgChannel <- VideoProcessingResult{ID: vNode.ID, Name: vNode.Name, Status: Failed, Message: err.Error()} return @@ -404,7 +480,7 @@ func (a *App) queryCreateProxyFile(userOpts video.ProcessingOpts) error { return err } - err = a.executeFFmpegQuery(query, false) + err = a.executeFFmpegQuery(query, NewMonitoringOpts(video.OBV_OUT_TIME)) if err != nil { return err } @@ -419,7 +495,7 @@ func (a *App) queryCreateThumbnail(userOpts video.ProcessingOpts) error { return err } - err = a.executeFFmpegQuery(query, false) + err = a.executeFFmpegQuery(query, nil) if err != nil { return err } @@ -428,7 +504,7 @@ func (a *App) queryCreateThumbnail(userOpts video.ProcessingOpts) error { } // executeFFmpegQuery: executes an ffmpeg query -func (a *App) executeFFmpegQuery(query string, withMonitoring bool) error { +func (a *App) executeFFmpegQuery(query string, monitoringOpts *MonitoringOpts) error { // TODO: implement windows cmd := exec.Command("bash", "-c", query) @@ -441,8 +517,8 @@ func (a *App) executeFFmpegQuery(query string, withMonitoring bool) error { if err != nil { return fmt.Errorf("could not initialize video export") } - if withMonitoring { - go a.monitorFFmpegOuput(stderrPipe) + if monitoringOpts != nil { + go a.monitorFFmpegOuput(stderrPipe, monitoringOpts) } err = cmd.Wait() @@ -453,7 +529,8 @@ func (a *App) executeFFmpegQuery(query string, withMonitoring bool) error { } // monitorFFmpegOuput: monitors ffmpeg query progress -func (a *App) monitorFFmpegOuput(FFmpegOut io.ReadCloser) { +func (a *App) monitorFFmpegOuput(FFmpegOut io.ReadCloser, monitoringOpts *MonitoringOpts) { + wruntime.LogInfo(a.ctx, "monitoring FFmpeg query") total, err := a.GetTrackDuration() if err != nil { return @@ -461,7 +538,7 @@ func (a *App) monitorFFmpegOuput(FFmpegOut io.ReadCloser) { scanner := bufio.NewScanner(FFmpegOut) for scanner.Scan() { line := scanner.Text() - if strings.Contains(line, "out_time_us") { + if strings.Contains(line, video.OBV_OUT_TIME_US) && monitoringOpts.terms[video.OBV_OUT_TIME_US] { args := strings.Split(line, "=") timeMicro, err := strconv.Atoi(args[1]) if err != nil { @@ -473,5 +550,92 @@ func (a *App) monitorFFmpegOuput(FFmpegOut io.ReadCloser) { } wruntime.EventsEmit(a.ctx, video.EVT_ENCODING_PROGRESS, (timeSeconds*100)/int(total)) } + if strings.Contains(line, video.OBV_OUT_TIME) && monitoringOpts.terms[video.OBV_OUT_TIME] { + args := strings.Split(line, "=") + if args[0] != video.OBV_OUT_TIME { + continue + } + duration, err := convertHMStoSeconds(args[1]) + if err != nil { + // TODO: handle conversion error + continue + } + wruntime.EventsEmit(a.ctx, video.EVT_DURATION_EXTRACTED, duration) + } } } + +func convertHMStoSeconds(hms string) (float64, error) { + parts := strings.Split(hms, ".") + if len(parts) != 2 { + return 0.0, fmt.Errorf("could not parse decimal part") + } + + timeParts := strings.Split(parts[0], ":") + if len(timeParts) != 3 { + return 0.0, fmt.Errorf("could not parse time part") + } + + hours, err := strconv.Atoi(timeParts[0]) + if err != nil { + return 0.0, fmt.Errorf("could not convert hours to int") + } + + minutes, err := strconv.Atoi(timeParts[1]) + if err != nil { + return 0.0, fmt.Errorf("could not convert minutes to int") + } + + seconds, err := strconv.Atoi(timeParts[2]) + if err != nil { + return 0.0, fmt.Errorf("could not convert seconds to int") + } + + microseconds, err := strconv.Atoi(parts[1]) + if err != nil { + return 0.0, fmt.Errorf("could not convert microseconds to int") + } + + totalSeconds := float64(hours*3600+minutes*60+seconds) + float64(microseconds)/1000000 + return totalSeconds, nil +} + +func getVideoDuration(userOpts video.ProcessingOpts) (float64, error) { + query, err := ffmpegbuilder.CheckVideoDuration(userOpts) + if err != nil { + return 0, err + } + cmd := exec.Command("bash", "-c", query) + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return 0, fmt.Errorf("could not initialize ffmpeg monitoring") + } + + scanner := bufio.NewScanner(stderrPipe) + + err = cmd.Start() + if err != nil { + return 0, fmt.Errorf("could not initialize video duration extraction") + } + + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, video.OBV_DURATION) { + hms := strings.Split(strings.TrimSpace(strings.Split(line, ",")[0]), "Duration: ")[1] + duration, err := convertHMStoSeconds(hms) + if err != nil { + continue + } + return duration, nil + } + + } + + err = cmd.Wait() + if err != nil { + return 0, fmt.Errorf("could not extract duration of the video") + } + return 0, nil + +}