diff --git a/audio/audio.go b/audio/audio.go new file mode 100644 index 0000000..1ed5be3 --- /dev/null +++ b/audio/audio.go @@ -0,0 +1,26 @@ +package audio + +import ( + "os" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" +) + +func PlayAudio(audioPath string) { + f, err := os.Open(audioPath) + if err != nil { + panic(err) + } + streamer, format, err := mp3.Decode(f) + defer streamer.Close() + speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + + done := make(chan bool) + speaker.Play(beep.Seq(streamer, beep.Callback(func() { + done <- true + }))) + <-done +} diff --git a/extractor/extractor.go b/extractor/extractor.go new file mode 100644 index 0000000..d661220 --- /dev/null +++ b/extractor/extractor.go @@ -0,0 +1,41 @@ +package extractor + +import ( + "fmt" + "path/filepath" + + "github.com/lijo-jose/gffmpeg/pkg/gffmpeg" +) + +const ( + ImageFileExtention string = "png" +) + +func GetFFMPEG(binPath string) gffmpeg.GFFmpeg { + GFFMPEG, err := gffmpeg.NewGFFmpeg(binPath) + if err != nil { + panic(err) + } + return GFFMPEG +} + +func ExtractFrames(gff gffmpeg.GFFmpeg, videoPath, outputPath string, fps int) { + builder := gffmpeg.NewBuilder() + builder = builder.SrcPath(videoPath).VideoFilters(fmt.Sprintf("fps=%v", fps)).DestPath(outputPath + "%10d." + ImageFileExtention) + gff.Set(builder) + output := gff.Start(nil) + if output.Err != nil { + panic(output.Err) + } + +} + +func ExtractAudio(gff gffmpeg.GFFmpeg, videoPath, outputPath string) { + builder := gffmpeg.NewBuilder() + builder = builder.SrcPath(videoPath).VideoFilters("-q:a -map a").DestPath(filepath.Join(outputPath, "extractedAudio.mp3")) + gff.Set(builder) + output := gff.Start(nil) + if output.Err != nil { + panic(output.Err) + } +} diff --git a/jsonData/jsonData.go b/jsonData/jsonData.go new file mode 100644 index 0000000..24f2f99 --- /dev/null +++ b/jsonData/jsonData.go @@ -0,0 +1,144 @@ +package jsonData + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" +) + +type Data struct { + MaxGOROUTINES uint + WIDTH uint + HEIGHT uint + ExtractionFPS int + AudioFilePath string + AudioPlayback bool + FFMPEGbin string + VideoFramesOutputPath string + InputVideo string + AsciiFilesPath string + AsciiChars []string +} + +const ( + jsonFilename string = "VideoToAsciiSettings.json" +) + +var ( + defaults = Data{ + + MaxGOROUTINES: 200, + WIDTH: 210, + HEIGHT: 60, + ExtractionFPS: 30, + AudioFilePath: ``, + AudioPlayback: false, + FFMPEGbin: `(must have)`, + VideoFramesOutputPath: ``, + InputVideo: `(must have)`, + AsciiFilesPath: ``, + AsciiChars: []string{" ", "░", "▒", "▓", "█"}, + } + currentDir, _ = os.Getwd() +) + +func checkIfJsonExist() bool { + files, err := ioutil.ReadDir(currentDir) + + if err != nil { + panic(err) + } + for _, file := range files { + if file.Name() == jsonFilename { + return true + } + } + return false +} + +// This Json MUST be contained in the same directory with a binary +func createJson() *os.File { + json, err := os.Create(filepath.Join(currentDir, jsonFilename)) + if err != nil { + panic(err) + } + return json +} + +func writeDefaults(jsonSettings *os.File) { + marshalledDefaults, err := json.MarshalIndent(defaults, "", " ") + if err != nil { + panic(err) + } + jsonSettings.Write(marshalledDefaults) +} + +func readFromJson() *Data { + file, err := ioutil.ReadFile(filepath.Join(currentDir, jsonFilename)) + if err != nil { + panic(err) + } + var data Data + json.Unmarshal(file, &data) + + videoFrames := filepath.Join(data.VideoFramesOutputPath) + asciiTxtPath := filepath.Join(data.AsciiFilesPath) + audioPath := filepath.Join(data.AudioFilePath) + + if videoFrames == "" || videoFrames == "." || videoFrames == " " { + data.VideoFramesOutputPath = currentDir + } + if asciiTxtPath == "" || asciiTxtPath == "." || asciiTxtPath == " " { + data.AsciiFilesPath = currentDir + } + if audioPath == "" || audioPath == "." || audioPath == " " { + data.AudioFilePath = currentDir + } + + return &data +} + +func createDirs(data *Data) { + videoFrames := filepath.Join(data.VideoFramesOutputPath) + asciiTxtPath := filepath.Join(data.AsciiFilesPath) + audioPath := filepath.Join(data.AudioFilePath) + if videoFrames == "" || videoFrames == "." || videoFrames == " " { + videoFrames = currentDir + } + if asciiTxtPath == "" || asciiTxtPath == "." || asciiTxtPath == " " { + asciiTxtPath = currentDir + } + if audioPath == "" || audioPath == "." || audioPath == " " { + audioPath = currentDir + } + err := os.MkdirAll(videoFrames, os.ModePerm) + if err != nil { + panic(err) + } + err = os.MkdirAll(asciiTxtPath, os.ModePerm) + if err != nil { + panic(err) + } + err = os.MkdirAll(audioPath, os.ModePerm) + if err != nil { + panic(err) + } +} + +func GetSettings() (*Data, bool) { + if checkIfJsonExist() == false { + jsonFile := createJson() + defer jsonFile.Close() + + writeDefaults(jsonFile) + data := readFromJson() + createDirs(data) + + return data, true + } + data := readFromJson() + createDirs(data) + + return data, false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7e3860d --- /dev/null +++ b/main.go @@ -0,0 +1,209 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "sort" + "sync" + + "github.com/nsf/termbox-go" + + "./audio" + "./extractor" + "./jsonData" + "./processor" +) + +var ( + settings, firstLaunch = jsonData.GetSettings() + WG sync.WaitGroup + // how many goroutines you want to be working on processing frames + MaxGOROUTINES uint = settings.MaxGOROUTINES + // width and height of an ascii text file + WIDTH uint = settings.WIDTH + HEIGHT uint = settings.HEIGHT + // the amount of frames/second you extract from video with ffmpeg + ExtractionFPS int = settings.ExtractionFPS + // where the audio file will go when you extract it from a video + AudioFilePath string = settings.AudioFilePath + // shows if you want to play audio or not + AudioPlayback bool = settings.AudioPlayback + // path to ffmpeg + FFMPEGbin = settings.FFMPEGbin + // this is where the frames from a video will be + VideoFramesOutputPath = settings.VideoFramesOutputPath + // path to a video + InputVideo = settings.InputVideo + // this is where the processed ascii textfiles will be + AsciiFilesPath = settings.AsciiFilesPath + // character set for "asciifying" images + asciiChars = settings.AsciiChars +) + +func main() { + // 1) extract frames && audio (optional) from a video + // 2) process videoframes into Ascii + // 3) it`s ready to play + if firstLaunch { + fmt.Println("Created settings file; closing in 10 seconds...") + time.Sleep(time.Second * 10) + os.Exit(0) + } + fmt.Print("Extraction mode (0), playback mode (1), processing images mode (2), Extract audio (3) ? (0,1,2,3) : ") + var input string + fmt.Scanln(&input) + if input == "0" || input == "0 " { + t0 := time.Now() + + fmt.Println("Extracting images...") + gff := extractor.GetFFMPEG(FFMPEGbin) + extractor.ExtractFrames(gff, InputVideo, VideoFramesOutputPath, ExtractionFPS) + + FinishTime := time.Now().Sub(t0) + fmt.Printf("Done in %v", FinishTime) + fmt.Scanln() + + } else if input == "1" || input == "1 " { + asciiFiles, err := ioutil.ReadDir(AsciiFilesPath) + if err != nil { + panic(err) + } + var Sequence []string + for _, file := range asciiFiles { + if file.Name()[len(file.Name())-3:] == "txt" { + frame, readErr := ioutil.ReadFile(filepath.Join(AsciiFilesPath, file.Name())) + if readErr != nil { + panic(readErr) + } + Sequence = append(Sequence, string(frame)) + } + } + err = termbox.Init() + if err != nil { + panic(err) + } + + timeForEachFrame := time.Duration(time.Second / time.Duration(ExtractionFPS)) + + t0 := time.Now() + + if AudioPlayback == true { + go audio.PlayAudio(filepath.Join(AudioFilePath, "extractedAudio.mp3")) + } + + var counter uint64 = 0 + var nextFrameTime time.Time = time.Now() + for { + if counter < uint64(len(Sequence)) { + now := time.Now() + if now.After(nextFrameTime) { + nextFrameTime = now.Add(timeForEachFrame) + showFrame(Sequence[counter]) + counter++ + } + } else { + termbox.Close() + break + } + } + fmt.Printf("Took %v", time.Now().Sub(t0)) + fmt.Scanln() + + } else if input == "2" || input == "2 " { + t0 := time.Now() + + fmt.Println("Processing images...") + + files, err := ioutil.ReadDir(VideoFramesOutputPath) + if err != nil { + panic(err) + } + + var sortedFilenames []string + + for _, f := range files { + if f.Name()[len(f.Name())-3:] == extractor.ImageFileExtention { + sortedFilenames = append(sortedFilenames, f.Name()) + } + } + sort.Strings(sortedFilenames) + + jobs := make(chan *processor.DataForAscii, len(sortedFilenames)) + + for i := 0; i < int(MaxGOROUTINES); i++ { + WG.Add(1) + go Worker(jobs, &WG) + } + + var counter uint64 = 0 + for { + + if counter == uint64(len(sortedFilenames)) { + break + } + if len(jobs) < int(MaxGOROUTINES) { + img, err := processor.GetImage(filepath.Join(VideoFramesOutputPath, sortedFilenames[counter])) + if err != nil { + panic(err) + } + + jobs <- &processor.DataForAscii{ + Img: img, + Width: WIDTH, + Height: HEIGHT, + Filename: fmt.Sprintf("%010d_ascii.txt", counter), + } + img = nil + counter++ + } + } + + close(jobs) + WG.Wait() + + FinishTime := time.Now().Sub(t0) + fmt.Printf("Done in %v", FinishTime) + fmt.Scanln() + + } else if input == "3" || input == "3 " { + t0 := time.Now() + + fmt.Println("Extracting audio...") + gff := extractor.GetFFMPEG(FFMPEGbin) + extractor.ExtractAudio(gff, InputVideo, AudioFilePath) + + FinishTime := time.Now().Sub(t0) + fmt.Printf("Done in %v", FinishTime) + fmt.Scanln() + + } +} + +func Worker(jobs <-chan *processor.DataForAscii, WG *sync.WaitGroup) { + defer WG.Done() + for data := range jobs { + processor.ASCIIfy(asciiChars, data.Img, data.Width, data.Height, filepath.Join(AsciiFilesPath, data.Filename)) + data = nil + } +} + +func showFrame(frame string) { + termbox.SetCursor(0, 0) + var x, y int = 0, 0 + for _, char := range frame { + termbox.HideCursor() + if string(char) == "\n" { + y++ + x = 0 + } else { + termbox.SetCell(x, y, char, termbox.ColorWhite, termbox.ColorBlack) + x++ + } + + } + termbox.Flush() +} diff --git a/processor/processor.go b/processor/processor.go new file mode 100644 index 0000000..cdcea69 --- /dev/null +++ b/processor/processor.go @@ -0,0 +1,94 @@ +package processor + +import ( + "image" + _ "image/jpeg" + "image/png" + + _ "image/png" + "os" + "path/filepath" + + "github.com/nfnt/resize" +) + +type DataForAscii struct { + Img *image.Image + Width uint + Height uint + Filename string +} + +// GetImage returns image.Image from a filepath +func GetImage(pathToFile string) (*image.Image, error) { + file, err := os.Open(filepath.Join(pathToFile)) + if err != nil { + return nil, err + } + defer file.Close() + + image, _, err := image.Decode(file) + if err != nil { + return nil, err + } + + return &image, nil +} + +// SaveImage takes an image.Image and saves it to a file +func SaveImage(filename string, img *image.Image) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + err = png.Encode(f, *img) + if err != nil { + return err + } + return nil +} + +// ResizeImage takes an image.Image and returns a resized one using https://github.com/nfnt/resize +func ResizeImage(img image.Image, newWidth uint, newHeight uint) image.Image { + resizedImage := resize.Resize(newWidth, newHeight, img, resize.Lanczos3) + return resizedImage +} + +// GetChar returns a char from chars corresponding to the pixel brightness +func GetChar(chars []string, pixelBrightness int) string { + charsLen := len(chars) + return chars[int((charsLen*pixelBrightness)/256)] +} + +// ASCIIfy converts and image.Image into ASCII art +func ASCIIfy(ASCIIchars []string, img *image.Image, cols, rows uint, filename string) { + + var resized image.Image + if cols == uint(0) || rows == uint(0) { + resized = *img + } else { + resized = ResizeImage(*img, cols, rows) + } + + imgBounds := resized.Bounds() + + f, err := os.Create(filename) + if err != nil { + panic(err) + } + defer f.Close() + + for y := 0; y < imgBounds.Max.Y; y++ { + for x := 0; x < imgBounds.Max.X; x++ { + r, g, b, _ := resized.At(x, y).RGBA() + r = r / 257 + g = g / 257 + b = b / 257 + currentPixelBrightness := int((float64(0.2126)*float64(r) + float64(0.7152)*float64(g) + float64(0.0722)*float64(b))) + f.Write([]byte(GetChar(ASCIIchars, currentPixelBrightness))) + } + f.Write([]byte("\n")) + } +}