diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bbe6090..797747b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: # Match any new tag - "*" +permissions: + contents: write + env: # Necessary for most environments as build failure can occur due to OOM issues NODE_OPTIONS: "--max-old-space-size=4096" @@ -17,9 +20,6 @@ jobs: fail-fast: false matrix: build: - - name: "Gahara" - platform: "linux/amd64" - os: "ubuntu-latest" - name: "Gahara" platform: "darwin/universal" os: "macos-latest" @@ -31,11 +31,16 @@ jobs: with: submodules: recursive + - name: Download FFmpeg for macOS + if: matrix.build.platform == 'darwin/universal' + run: | + chmod +x hack/setup.sh + ./hack/setup.sh ${{matrix.build.platform}} + - name: Build wails uses: dAppServer/wails-build-action@v2.2 id: build with: build-name: ${{ matrix.build.name }} build-platform: ${{ matrix.build.platform }} - package: false go-version: "1.22" diff --git a/.gitignore b/.gitignore index 843c879..ea091e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +resources/ node_modules frontend/dist .DS_STORE diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a68254b --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: ffmpeg +ffmpeg: + @echo "Setting up FFmpeg..." + @chmod +x ./hack/setup.sh + @./hack/setup.sh darwin/universal + + +.PHONY: cleanup +cleanup: + @echo "Teardown..." + @rm -rf ./resources/ diff --git a/app.go b/app.go index fa03d70..50efdd0 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "path" "path/filepath" "strings" + "time" "runtime" @@ -38,6 +39,8 @@ type App struct { config Config // Timeline: the project timeline Timeline video.Timeline `json:"timeline"` + // FFmpegPath: the configured ffmpeg on build + FFmpegPath string } // NewApp creates a new App application struct @@ -51,8 +54,25 @@ func (a *App) startup(ctx context.Context) { a.ctx = ctx err := a.gaharaSetup() if err != nil { - wruntime.LogFatal(ctx, err.Error()) + wruntime.LogFatal(a.ctx, err.Error()) } + FFmpegPath, err := ExtractFFmpeg() + if err != nil { + wruntime.LogFatal(a.ctx, fmt.Sprintf("could not initialize FFmpeg: %s", err.Error())) + } + a.FFmpegPath = FFmpegPath + wruntime.LogInfo(a.ctx, fmt.Sprintf("initialized FFmpeg at %s", a.FFmpegPath)) +} + +func (a *App) cleanup(ctx context.Context) { + _, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + err := os.RemoveAll(filepath.Dir(a.FFmpegPath)) + if err != nil { + wruntime.LogError(a.ctx, "could not cleanup FFmpeg") + } + wruntime.LogInfo(a.ctx, "FFmpeg was cleaned") } // FilePicker: opens the native file picker for the user @@ -208,7 +228,7 @@ func (a *App) ReadProjectWorkspace() ([]Video, error) { continue } - duration, err := getVideoDuration(video.ProcessingOpts{ + duration, err := getVideoDuration(a.FFmpegPath, video.ProcessingOpts{ Filename: strings.Split(project.Name(), ".")[0], VideoFormat: filepath.Ext(project.Name()), InputPath: a.config.ProjectDir, diff --git a/darwin.go b/darwin.go new file mode 100644 index 0000000..82a060f --- /dev/null +++ b/darwin.go @@ -0,0 +1,31 @@ +//go:build darwin + +package main + +import ( + "embed" + "os" + "path/filepath" +) + +//go:embed resources/darwin/ffmpeg +var FFmpegBinary embed.FS + +func ExtractFFmpeg() (string, error) { + data, err := FFmpegBinary.ReadFile("resources/darwin/ffmpeg") + if err != nil { + return "", err + } + + tempDir, err := os.MkdirTemp("", "ffmpeg") + if err != nil { + return "", err + } + + ffmpegPath := filepath.Join(tempDir, "ffmpeg") + err = os.WriteFile(ffmpegPath, data, 0755) + if err != nil { + return "", err + } + return ffmpegPath, nil +} diff --git a/ffmpegbuilder/ffmpegbuilder.go b/ffmpegbuilder/ffmpegbuilder.go index 1cf7bc4..8cd8ff5 100644 --- a/ffmpegbuilder/ffmpegbuilder.go +++ b/ffmpegbuilder/ffmpegbuilder.go @@ -8,6 +8,7 @@ import ( ) type FFmpegBuilder struct { + FFmpegPath string PreInputParams PreInputParams Inputs []string FilterGraphParams FilterGraphParams @@ -67,8 +68,9 @@ type OutputParams struct { NullOutput string } -func NewDefaultFFmpegBuilder() *FFmpegBuilder { +func NewDefaultFFmpegBuilder(FFmpegPath string) *FFmpegBuilder { return &FFmpegBuilder{ + FFmpegPath: FFmpegPath, PreInputParams: NewDefaultPreInputParams(), Inputs: []string{}, ComplexFilterGraph: []string{}, @@ -223,7 +225,7 @@ func (f *FFmpegBuilder) ConcatFilter(videoNodes []video.VideoNode) (string, erro // BuildQuery: returns the ffmpeg query with all the parameters given func (f *FFmpegBuilder) BuildQuery() (string, error) { var cmd strings.Builder - cmd.WriteString("ffmpeg ") + cmd.WriteString(fmt.Sprintf("%s ", f.FFmpegPath)) if f.PreInputParams.HideBanner { cmd.WriteString("-hide_banner ") diff --git a/ffmpegbuilder/ffmpegbuilder_test.go b/ffmpegbuilder/ffmpegbuilder_test.go index 31e048b..541e0f3 100644 --- a/ffmpegbuilder/ffmpegbuilder_test.go +++ b/ffmpegbuilder/ffmpegbuilder_test.go @@ -22,7 +22,7 @@ func TestFFmpegBuilder(t *testing.T) { t.Run("format conversion query", func(t *testing.T) { expectedQuery := "ffmpeg -hide_banner -v quiet -stats_period 5s -progress pipe:2 -i \"myinput.mp4\" \"myoutput.mov\" " - query, err := NewDefaultFFmpegBuilder().WithInputs("myinput.mp4").WithOutputs("myoutput.mov").BuildQuery() + query, err := NewDefaultFFmpegBuilder("ffmpeg").WithInputs("myinput.mp4").WithOutputs("myoutput.mov").BuildQuery() if err != nil { t.Fatal(err) } @@ -33,7 +33,7 @@ func TestFFmpegBuilder(t *testing.T) { t.Run("generate proxy file query", func(t *testing.T) { expectedQuery := "ffmpeg -hide_banner -v quiet -stats_period 5s -progress pipe:2 -i \"inputpath/input.mp4\" -c copy \"outputpath/input.mov\" " - query, err := CreateProxyFileQuery(video.ProcessingOpts{ + query, err := CreateProxyFileQuery("ffmpeg", video.ProcessingOpts{ Filename: "input", InputPath: "inputpath", OutputPath: "outputpath", @@ -49,7 +49,7 @@ func TestFFmpegBuilder(t *testing.T) { t.Run("generate thumbnail query", func(t *testing.T) { expectedQuery := "ffmpeg -hide_banner -v quiet -stats_period 5s -progress pipe:2 -i \"inputpath/input.mp4\" -frames:v 1 \"outputpath/input.png\" " - query, err := CreateThumbnailQuery(video.ProcessingOpts{ + query, err := CreateThumbnailQuery("ffmpeg", video.ProcessingOpts{ Filename: "input", InputPath: "inputpath", OutputPath: "outputpath", @@ -66,7 +66,7 @@ func TestFFmpegBuilder(t *testing.T) { t.Run("concat filter query", func(t *testing.T) { expectedQuery := "ffmpeg -hide_banner -v quiet -stats_period 5s -progress pipe:2 -i \"root1\" -i \"root2\" -i \"root3\" -filter_complex \"[0:v]trim=start=20.1000:end=25.2000,setpts=PTS-STARTPTS,scale=1920x1080[v0];[0:v]trim=start=1.1200:end=10.2000,setpts=PTS-STARTPTS,scale=1920x1080[v1];[1:v]trim=start=12.2000:end=21.2000,setpts=PTS-STARTPTS,scale=1920x1080[v2];[2:v]trim=start=69.1120:end=80.2300,setpts=PTS-STARTPTS,scale=1920x1080[v3];[v0][v1][v2][v3]concat=n=4:v=1:a=0[out]\" -map \"[out]\" -c:v libx264 -crf 18 -preset medium \"outputpath/myvideo.mp4\" " - query, err := MergeClipsQuery(mockTl().VideoNodes, video.ProcessingOpts{ + query, err := MergeClipsQuery("ffmpeg", mockTl().VideoNodes, video.ProcessingOpts{ Resolution: "1920x1080", Codec: "libx264", CRF: "18", @@ -90,7 +90,7 @@ func TestFFmpegBuilder(t *testing.T) { expectedQuery := fmt.Sprintf("ffmpeg -hide_banner -v quiet -stats_period 5s -progress pipe:2 -ss 22.2300 -i \"root1\" -t %.4f -avoid_negative_ts make_zero -c copy -movflags '+faststart' \"outputpath/myvideo.mp4\" ", duration) - query, err := LosslessCutQuery(videoNode, video.ProcessingOpts{ + query, err := LosslessCutQuery("ffmpeg", videoNode, video.ProcessingOpts{ OutputPath: "outputpath", Filename: videoNode.Name, VideoFormat: ".mp4", diff --git a/ffmpegbuilder/query.go b/ffmpegbuilder/query.go index a02a833..aae9e46 100644 --- a/ffmpegbuilder/query.go +++ b/ffmpegbuilder/query.go @@ -5,10 +5,10 @@ import ( "github.com/k1nho/gahara/internal/video" ) -func CheckVideoDuration(userOpts video.ProcessingOpts) (string, error) { +func CheckVideoDuration(FFmpegPath string, userOpts video.ProcessingOpts) (string, error) { input := GetFullInputPath(userOpts) - query, err := NewDefaultFFmpegBuilder().WithInputs(input). + query, err := NewDefaultFFmpegBuilder(FFmpegPath).WithInputs(input). WithNullOutput().WithVerbose("").BuildQuery() if err != nil { return "", err @@ -17,12 +17,12 @@ func CheckVideoDuration(userOpts video.ProcessingOpts) (string, error) { } // CreateProxyFileQuery: creates a proxy file for a video -func CreateProxyFileQuery(userOpts video.ProcessingOpts, format string) (string, error) { +func CreateProxyFileQuery(FFmpegPath string, userOpts video.ProcessingOpts, format string) (string, error) { input := GetFullInputPath(userOpts) userOpts.VideoFormat = format output := GetFullOutputPath(userOpts) - querybuilder := NewDefaultFFmpegBuilder().WithInputs(input).WithCodec("copy"). + querybuilder := NewDefaultFFmpegBuilder(FFmpegPath).WithInputs(input).WithCodec("copy"). WithOutputs(output) if err := querybuilder.validateProxyFileCreationQuery(); err != nil { @@ -38,12 +38,12 @@ func CreateProxyFileQuery(userOpts video.ProcessingOpts, format string) (string, } // CreateThumbnailQuery: generates a thumbnail taking the 1 frame of a video -func CreateThumbnailQuery(userOpts video.ProcessingOpts, format string) (string, error) { +func CreateThumbnailQuery(FFmpegPath string, userOpts video.ProcessingOpts, format string) (string, error) { input := GetFullInputPath(userOpts) userOpts.VideoFormat = format output := GetFullOutputPath(userOpts) - query, err := NewDefaultFFmpegBuilder().WithInputs(input).WithScale(userOpts.Resolution). + query, err := NewDefaultFFmpegBuilder(FFmpegPath).WithInputs(input).WithScale(userOpts.Resolution). WithVideoFrames("1").WithOutputs(output).BuildQuery() if err != nil { return "", err @@ -53,8 +53,8 @@ func CreateThumbnailQuery(userOpts video.ProcessingOpts, format string) (string, } // MergeClipsQuery: returns the query to concatenate a series of video nodes -func MergeClipsQuery(videoNodes []video.VideoNode, userOpts video.ProcessingOpts) (string, error) { - querybuilder := NewDefaultFFmpegBuilder().WithInputs(ExtractInputs(videoNodes)...). +func MergeClipsQuery(FFmpegPath string, videoNodes []video.VideoNode, userOpts video.ProcessingOpts) (string, error) { + querybuilder := NewDefaultFFmpegBuilder(FFmpegPath).WithInputs(ExtractInputs(videoNodes)...). WithPreset(userOpts.Preset).WithCRF(userOpts.CRF).WithVideoCodec(userOpts.Codec). WithFScale(userOpts.Resolution).WithOutputs(GetFullOutputPath(userOpts)) @@ -76,11 +76,11 @@ func MergeClipsQuery(videoNodes []video.VideoNode, userOpts video.ProcessingOpts } // LosslessCutQuery: returns the query string to make a lossless cut of a video node -func LosslessCutQuery(videoNode video.VideoNode, userOpts video.ProcessingOpts) (string, error) { +func LosslessCutQuery(FFmpegPath string, videoNode video.VideoNode, userOpts video.ProcessingOpts) (string, error) { // overwrite filename, if it was passed by default lossy opts userOpts.Filename = videoNode.Name - querybuilder := NewDefaultFFmpegBuilder().WithInputs(videoNode.RID).WithInputStartTime(videoNode.Start). + querybuilder := NewDefaultFFmpegBuilder(FFmpegPath).WithInputs(videoNode.RID).WithInputStartTime(videoNode.Start). WithOutputDuration(videoNode.End - videoNode.Start).WithCodec("copy").WithAvoidNegativeTS("make_zero"). WithMovFlags("+faststart").WithOutputs(GetFullOutputPath(userOpts)) diff --git a/hack/setup.sh b/hack/setup.sh new file mode 100755 index 0000000..f4a85cd --- /dev/null +++ b/hack/setup.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +PLATFORM=$1 + +if [[ "$PLATFORM" == "darwin/universal" ]]; then + echo "Setting up FFmpeg for macOS" + curl -O https://evermeet.cx/ffmpeg/ffmpeg-115960-g3a5202d026.zip + unzip ffmpeg-115960-g3a5202d026.zip + mkdir -p resources/darwin/ + mv ffmpeg resources/darwin/ffmpeg + chmod +x resources/darwin/ffmpeg + rm -rf ffmpeg-115960-g3a5202d026.zip +elif [[ "$PLATFORM" == "linux/amd64" ]]; then + echo "Setting up FFmpeg for Linux" +elif [[ "$PLATFORM" == "windows" ]]; then + echo "Setting up FFmpeg for Windows" +else + echo "Unsupported platform: $PLATFORM" + exit 1 +fi diff --git a/internal/video/video.go b/internal/video/video.go index 343577e..d06f5be 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -310,16 +310,6 @@ func (tl *Timeline) DeleteRIDReferences(rid string) error { return nil } -// CreateProxyFile: creates a copy file from the original to preserve original and work with the given video clip -func CreateProxyFileCMD(inputFilePath, outputFilePath string) *exec.Cmd { - return exec.Command("ffmpeg", - "-i", inputFilePath, // input - "-codec", "copy", - "-strict", "experimental", - outputFilePath) - -} - // GenerateEditThumbnail: generate a thumbnail from a video func GenerateEditThumb(inputFilePath string, outputFilePath string, opts ThumbnailOpts) *exec.Cmd { return exec.Command("ffmpeg", diff --git a/linux.go b/linux.go new file mode 100644 index 0000000..76ccbb0 --- /dev/null +++ b/linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package main + +import "fmt" + +func ExtractFFmpeg() (string, error) { + return "", fmt.Errorf("unsupported platform") +} diff --git a/main.go b/main.go index 585e0e4..b05a580 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,7 @@ func main() { }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + OnShutdown: app.cleanup, Menu: app.AppMenu(), Bind: []interface{}{ app, diff --git a/video.go b/video.go index 623fb3a..eeb7491 100644 --- a/video.go +++ b/video.go @@ -428,7 +428,7 @@ func (a *App) FFmpegQuery(queryType string, userOpts video.ProcessingOpts) error // queryFiltergraph: executes a filtergraph query, currently merge clips func (a *App) queryFiltergraph(userOpts video.ProcessingOpts) error { - query, err := ffmpegbuilder.MergeClipsQuery(a.Timeline.VideoNodes, userOpts) + query, err := ffmpegbuilder.MergeClipsQuery(a.FFmpegPath, a.Timeline.VideoNodes, userOpts) if err != nil { return err } @@ -464,7 +464,7 @@ func (a *App) queryLosslessCut(userOpts video.ProcessingOpts) error { go func(vNode video.VideoNode) { defer wg.Done() userOpts.Filename = vNode.Name - query, err := ffmpegbuilder.LosslessCutQuery(vNode, userOpts) + query, err := ffmpegbuilder.LosslessCutQuery(a.FFmpegPath, vNode, userOpts) if err != nil { msgChannel <- VideoProcessingResult{ID: vNode.ID, Status: Failed, Message: err.Error()} return @@ -483,11 +483,10 @@ func (a *App) queryLosslessCut(userOpts video.ProcessingOpts) error { // queryCreateProxyFile: executes a conversion query for the given video func (a *App) queryCreateProxyFile(userOpts video.ProcessingOpts) error { - query, err := ffmpegbuilder.CreateProxyFileQuery(userOpts, ".mov") + query, err := ffmpegbuilder.CreateProxyFileQuery(a.FFmpegPath, userOpts, ".mov") if err != nil { return err } - err = a.executeFFmpegQuery(query, NewMonitoringOpts(video.OBV_OUT_TIME)) if err != nil { return err @@ -498,7 +497,7 @@ func (a *App) queryCreateProxyFile(userOpts video.ProcessingOpts) error { // queryCreateThumbnail: executes a query to generate a thumbnail for a video (picks 1st frame) func (a *App) queryCreateThumbnail(userOpts video.ProcessingOpts) error { - query, err := ffmpegbuilder.CreateThumbnailQuery(userOpts, ".png") + query, err := ffmpegbuilder.CreateThumbnailQuery(a.FFmpegPath, userOpts, ".png") if err != nil { return err } @@ -568,6 +567,9 @@ func (a *App) monitorFFmpegOuput(FFmpegOut io.ReadCloser, monitoringOpts *Monito // TODO: handle conversion error continue } + if duration < video.Epsilon { + continue + } wruntime.EventsEmit(a.ctx, video.EVT_DURATION_EXTRACTED, duration) } } @@ -608,8 +610,8 @@ func convertHMStoSeconds(hms string) (float64, error) { return totalSeconds, nil } -func getVideoDuration(userOpts video.ProcessingOpts) (float64, error) { - query, err := ffmpegbuilder.CheckVideoDuration(userOpts) +func getVideoDuration(FFmpegPath string, userOpts video.ProcessingOpts) (float64, error) { + query, err := ffmpegbuilder.CheckVideoDuration(FFmpegPath, userOpts) if err != nil { return 0, err } diff --git a/windows.go b/windows.go new file mode 100644 index 0000000..0380c19 --- /dev/null +++ b/windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "fmt" + +func ExtractFFmpeg() (string, error) { + return "", fmt.Errorf("unsupported platform") +}