commit 6e2ed13e3896bb0ff77f23f7e3df9387b0532fb2 Author: Mason Payne Date: Fri Dec 22 23:14:48 2023 -0700 create a cli tool for turning images into videos diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c7e23a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +i2v.exe +run_i2v.exe +run_i2v_gjr.exe +output.mp4 +resp.txt +test.png \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/i2v.iml b/.idea/i2v.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/i2v.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..12b3ba3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go new file mode 100644 index 0000000..82170e2 --- /dev/null +++ b/cmd/cli/cli.go @@ -0,0 +1,284 @@ +package main + +import ( + "bytes" + b64 "encoding/base64" + "encoding/json" + "fmt" + "github.com/urfave/cli/v2" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path" + "strings" +) + +func main() { + commands := []*cli.Command{ + //startCmd(), + getJobResultCmd(), + } + + app := &cli.App{ + Name: "i2v", + Usage: "A command line tool to convert images to videos, based on Stability.ai's Rest API.", + Version: "v1.0.0", + Description: "i2v (Image to Video) is a command line tool to convert images to videos, based on Stability.ai's Rest API.", + Commands: commands, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Value: "./output.mp4", + Usage: "path to output file (should be a mp4)", + Aliases: []string{ + "o", + }, + }, + }, + Authors: []*cli.Author{ + { + Name: "Mason Payne", + Email: "mason@masonitestudios.com", + }, + }, + Copyright: "2023 Masonite Studios LLC", + UseShortOptionHandling: true, + Action: func(c *cli.Context) error { + // use the first argument as the file name + fileLocation := c.Args().Get(0) + // if no argument is provided, use stdin + if fileLocation == "" { + fmt.Println("No file provided, using stdin.") + // read from stdin + var err error + fileLocationBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("error reading from stdin | %w", err) + } + fileLocation = string(fileLocationBytes) + } + + // TODO: preprocess the image to make sure it is the right size + /* + Supported Dimensions: + 1024x576 + 576x1024 + 768x768 + */ + + // make sure the image is either jpeg or png + fileMimeType := mime.TypeByExtension(path.Ext(fileLocation)) + if fileMimeType != "image/jpeg" && fileMimeType != "image/png" { + return fmt.Errorf("unsupported file type | %v", fileMimeType) + } + + id, err := initiateGeneratingAnimation(fileLocation) + if err != nil { + return fmt.Errorf("error making request | %w", err) + } + + fmt.Println("Video is being rendered, this may take a while.") + fmt.Printf("Job ID: %v\n", id) + + // wait for the job to finish + err = job(id, c.String("output")) + if err != nil { + return fmt.Errorf("error getting job result | %w", err) + } + + return nil + }, + } + //fmt.Println("This package will be used for interacting with a running StormV2 service via a terminal or command line.") + err := app.Run(os.Args) + if err != nil { + fmt.Println(fmt.Errorf("error running app | %w", err)) + return + } +} + +func initiateGeneratingAnimation(fileLocation string) (string, error) { + // get base filename from file location + filename := path.Base(fileLocation) + + // Create a buffer to store the request body + bodyBuf := &bytes.Buffer{} + + // Create a multipart writer with the buffer + writer := multipart.NewWriter(bodyBuf) + + // Add form fields + err := writer.WriteField("seed", "0") + if err != nil { + return "", fmt.Errorf("error writing form field seed | %w", err) + } + err = writer.WriteField("cfg_scale", "2.5") + if err != nil { + return "", fmt.Errorf("error writing form field cfg_scale | %w", err) + } + err = writer.WriteField("motion_bucket_id", "40") + if err != nil { + return "", fmt.Errorf("error writing form field motion_bucket_id | %w", err) + } + + // Add a file + file, err := os.Open(fileLocation) + if err != nil { + return "", fmt.Errorf("error opening file | %w", err) + } + defer file.Close() + + // Create a form file part + //filePart, err := writer.CreateFormFile("image", filename) + filePart, err := CreateImageFormFile(writer, filename) + if err != nil { + return "", fmt.Errorf("error creating form file | %w", err) + } + + // Copy the file content to the form file part + _, err = io.Copy(filePart, file) + if err != nil { + return "", fmt.Errorf("error copying file content | %w", err) + } + + // Close the multipart writer + writer.Close() + + url := "https://api.stability.ai/v2alpha/generation/image-to-video" + + // Create the HTTP request + req, err := http.NewRequest("POST", url, bodyBuf) + if err != nil { + return "", fmt.Errorf("error creating request | %w", err) + } + + // Set the Content-Type header + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Add("authorization", "Bearer "+os.Getenv("STABILITY_API_KEY")) + + // Make the HTTP request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("error making request | %w", err) + } + defer resp.Body.Close() + + // Print the response status and body + fmt.Println("Status:", resp.Status) + fmt.Println("Body:", resp.Body) + + //bodyBytes, err := io.ReadAll(resp.Body) + //if err != nil { + // return "", fmt.Errorf("error reading response body | %w", err) + //} + //bodyString := string(bodyBytes) + //fmt.Println(bodyString) + + res := struct { + ID string `json:"id"` + Name string `json:"name"` + Errors []string `json:"errors"` + }{} + + // decode response body + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return "", fmt.Errorf("error decoding response body | %w", err) + } + + // print response body + if res.Name != "" { + return "", fmt.Errorf("error generating animation | %v: Errors: %v", res.Name, strings.Join(res.Errors, ", ")) + } + + return res.ID, nil +} + +func CreateImageFormFile(w *multipart.Writer, filename string) (io.Writer, error) { + fileMimeType := mime.TypeByExtension(path.Ext(filename)) + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "image", filename)) + h.Set("Content-Type", fileMimeType) + return w.CreatePart(h) +} + +func getJobResult(jobID string) (string, bool, error) { + url := fmt.Sprintf("https://api.stability.ai/v2alpha/generation/image-to-video/result/%s", jobID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", false, fmt.Errorf("error creating request | %w", err) + } + + req.Header.Add("authorization", "Bearer "+os.Getenv("STABILITY_API_KEY")) + req.Header.Add("accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", false, fmt.Errorf("error making request | %w", err) + } + defer resp.Body.Close() + + res := struct { + Video string `json:"video"` + FinishReason string `json:"finishReason"` + Seed int64 `json:"seed"` + ID string `json:"id"` + Status string `json:"status"` + Name string `json:"name"` + Errors []string `json:"errors"` + }{} + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return "", false, fmt.Errorf("error unmarshalling response body | %w", err) + } + + if res.Name != "" { + return "", false, fmt.Errorf("error generating animation | %v: Errors: %v", res.Name, strings.Join(res.Errors, ", ")) + } + + if res.Status == "in-progress" { + return "", false, nil + } + + return res.Video, true, nil +} + +func job(id, outputLocation string) error { + var video string + var finished bool + var err error + // poll the job result until it is finished + for { + video, finished, err = getJobResult(id) + if err != nil { + return fmt.Errorf("error getting job result | %w", err) + } + + if finished { + fmt.Println("Video has completed rendering.") + break + } + } + + // decode the video, it is in base64 and is expected to be a mp4 + decodedVideo, err := b64.StdEncoding.DecodeString(video) + if err != nil { + return fmt.Errorf("error decoding video | %w", err) + } + + // write the video to the current directory + err = os.WriteFile(outputLocation, decodedVideo, 0644) + if err != nil { + return fmt.Errorf("error writing video to file | %w", err) + } + + fmt.Println("Video has been saved to ", outputLocation) + return nil +} diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go new file mode 100644 index 0000000..4c5cc76 --- /dev/null +++ b/cmd/cli/commands.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "github.com/urfave/cli/v2" +) + +//func startCmd() *cli.Command { +// return &cli.Command{ +// Name: "start", +// Aliases: []string{"s"}, +// Usage: "Start the application service", +// Action: func(c *cli.Context) error { +// +// return nil +// }, +// } +//} + +func getJobResultCmd() *cli.Command { + return &cli.Command{ + Name: "get-job-result", + Aliases: []string{"gjr"}, + Usage: "Get the result of a job", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "job-id", + Aliases: []string{"id"}, + Usage: "The id of the job to get the result of", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + // get the job id + jobID := c.String("job-id") + + // get the output file + output := c.String("output") + + // get the job result + err := job(jobID, output) + if err != nil { + return fmt.Errorf("error running job result checker | %w", err) + } + + return nil + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4ed2ae --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module i2v + +go 1.21 + +require github.com/urfave/cli/v2 v2.26.0 + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1d06da2 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= +github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=