11
\$\begingroup\$

I wrote a Python script to generate a video file of typing the contents of any program file. It works, but I'm really not very happy with how it works. I have several issues with it.

  1. In order to generate the video file I had to generate an image for every character in the file. As you can imagine, this is extremely time consuming, especially for longer files of code. I had originally wanted to only obtain the numpy array or bytes rather than having to save it to a file, but I didn't find an efficient way to generate the video file from this, nor how to obtain the array from the Pygments function highlight. I tried saving the value returned from the function into a variable and found it to be a list of bytes, but when I tried to use the Pillow Image function from_bytes(), it returned an error saying something to the effect of the bytes array being too small or not having enough data or something like that. I assume this is because the initial image saved is a blank image. I did this because I needed to get the size of the final image that would be generated so that I could keep all preceding images at the same size. Otherwise, if all the images produced at differing sizes, the output video would turn into a garbled mess. I haven't thought of a better solution to generating the frames of the video but I know there has to be one.

GIF animation of output video

  1. I noticed that the quality of the output video produced from this script decreases with each frame. It gets worse and worse quality over time. I am pretty sure this is due to each frame overlapping the last one. Since each frame is identical to the frame before it with the addition of another character, I believe the overlapping of the frames on top of each other creates the unpleasant blurriness (I don't know if that's the right way to describe it) that is seen in the output video. It's a subtle effect that is more noticeable when the font size is smaller. I've provided a GIF to show what I mean, with the font size set to 14. Are the frames overlapping each other? Would this issue be fixed by generating the video from the collection of image files?
  2. I know I made extensive use of property in this program. I have a feeling I used them at times when I shouldn't have. I was pretty happy with myself for using the property feature for the progress bar, since it allowed me to generate the progress bar just by incrementing self.progress. I probably don't need the other two properties, and I think I ended up not even using one of them in the end. I used the property method for updating the progress bar because I found it simpler to use and because when I tested two versions of the code, one which used property and the other which used threading.Thread and output the runtime for each, the version which used property ended up running faster than the multithreading one. Granted, I didn't extensively test this, so this may not be true in all cases.
  3. I am also aware that it would have been more efficient and better practice for me to use **kwargs and pass along the dictionary of values that way instead of just saving the dictionary itself as self.args. To be honest, I only found out about **kwargs a couple days ago and I haven't had the chance to go back and change this. I would still appreciate advice on implementing that though.
  4. I'm also unhappy with having to use both OpenCV and Pillow to generate the video. I feel like this is redundant and I could accomplish the same task with just one of these libraries rather than depending on both.

Overall, I'm unhappy with how excruciatingly slowly this script is. I am confident that there are better design choices I could make in order to improve both the runtime and the quality of the output produced.

class Typewriter:
    def __init__(self,args):
        self.args = args
        if not args.language is None:
            self.lexer = get_lexer_by_name(args.language)
        else:
            self.lexer = get_lexer_for_filename(args.textfile)

        logger.debug(f'Assigned value to lexer object {self.lexer}')
        self.formatter = self.__get_formatter()
        logger.debug(f'Assigned value to formatter object {self.formatter}')
        self._image_paths = []
        self._progress=0
        self._impath=''
        self.images = []
        self.total=0
        self.total_size=0
        try:
            with open(args.textfile,'r') as f:
                self._content = f.read()
        except FileNotFoundError as err:
            logger.error(err)
            sys.exit(-1)

    def __get_formatter(self):
        return ImageFormatter(line_numbers=True,style=self.args.style,line_number_bg=None,font_size=self.args.font_size)

    def __get_lexer(self,content):
        if not args.language is None:
            lexer = get_lexer_by_name(self.args.language)
        else:
            lexer = guess_lexer(content)

        return lexer

    def __update_progress(self):
        '''
        Adapted into fewer lines from the following gist:
        https://gist.github.com/vladignatyev/06860ec2040cb497f0f3
        '''
        sys.stdout.write(f'[{"="*int(round(50*self.progress/float(self.total)))+"-"*(50-int(round(50*self.progress/float(self.total))))} {round(100.0*self.progress/float(self.total),1)}%]\r')
        sys.stdout.flush()

    def __make_dir(self):
        choice=''
        if not os.path.exists('tmp'):
            os.makedirs('tmp')
        else:
            while not choice == 'y':
                logger.warning('There is already a directory named "tmp" here. Please make sure you are okay with everything in this "tmp" folder getting deleted before continuing.')
                choice=input('Continue? y/n\n')
                if choice == 'y':
                    break
                elif choice == 'n':
                    sys.exit(-1)
                else:
                    logger.warning('Invalid choice. Please type "y" or "n".\n')
                    continue

    @property
    def content(self):
        return self._content

    @content.setter
    def content(self,val):
        self._content=val
        self.__get_lexer(self._content)
        self.__get_formatter()

    @property
    def progress(self):
        return self._progress

    @progress.setter
    def progress(self,val):
        self._progress=val
        self.__update_progress()

    @property
    def impath(self):
        return self._impath

    @impath.setter
    def impath(self,val):
        self._impath=val
        self._image_paths.append(self._impath)
        old = Image.open(self._impath)
        new=Image.new('RGBA',self.total_size)
        new.paste(old)
        new.save(self._impath)
        self.images.append(new)

    @property
    def image_paths(self):
        return self._image_paths

    @image_paths.setter
    def image_paths(self,val):
        self._image_paths=val
        self.images=[Image.open(f) for f in self._image_paths]

    def generate_images(self,content=None):
        '''
        Generate Images
        ---------------
        Description: Creates a list of image data given text content
        '''
        if content is None:
            content=self.content
        else:
            self.content = content

        self.__make_dir()
        self.total=len(content)
        code = ''
        logger.info('Generating images...')
        # self.total_size=self.formatter._get_image_size(80,len(content.splitlines()))
        blank='\n'.join([' '*len(line) for line in content.splitlines()])
        last_impath=os.path.join('tmp',f'tmp_{str(len(content)).zfill(5)}.png')
        highlight(blank,self.lexer,self.formatter,outfile=last_impath)

        im=Image.open(last_impath).convert('RGBA')
        self.total_size=im.size
        self.images.append(im)
        os.remove(last_impath)
        for line in content:
            for ch in line:
                self.progress+=1
                impath=os.path.join('tmp',f'tmp_{str(self.progress).zfill(5)}.png')
                highlight(code,self.lexer,self.formatter,outfile=impath)
                self.impath=impath
                code += ch

        print()
        return self.image_paths

    def generate_movie(self,image_paths=None,fps=10,moviename='movie.avi'):
        '''
        Generate Movie
        --------------
        Description: Creates an animation of the images generated above,
                     and then cleans up the tmp files by removing everything
                     in the tmp directory that was generated.
        '''
        # If it's run without a list of images and we haven't generated any images yet,
        # then generate the images and set the var to that
        logger.info('Generating movie...')
        if image_paths is None and not self.image_paths:
            image_paths=self.generate_images(self.content)
        # If we have already generated the images then set the var to that
        elif image_paths is None:
            image_paths=self.image_paths
        # If they gave us the list of images, put out something to make sure
        # user knows to keep the content matching
        else:
            logger.info('Make sure the content of the images matches the content var in this class!')

        frame=cv2.imread(self.image_paths[0])
        h,w,l=frame.shape
        v=cv2.VideoWriter(moviename,0,fps,(w,h))
        self.progress=0
        self.total=len(self.image_paths)+1
        for im in self.image_paths:
            v.write(cv2.imread(im))
            os.remove(im)
            self.progress += 1

        os.rmdir('tmp')
        self.progress+=1
        print()
\$\endgroup\$
  • 1
    \$\begingroup\$ The code, as is, doesn't run. \$\endgroup\$ – C. Harley Jun 7 at 17:49
  • \$\begingroup\$ Just to confirm, you want to record a video of code as you are typing. What's the objective? Can you please elaborate more on why you want to do this? Also are you using this code to capture a code editor's window as you type? \$\endgroup\$ – Vishesh Mangla Jul 10 at 15:39
  • 3
    \$\begingroup\$ Do you have a usage example showing how to actually get the result you show with the class provided? Please don't forget to mention relevant imports as well. Your question has gone unanswered so far while I think it's perfectly answerable provided you add the aforementioned items. \$\endgroup\$ – Mast Jul 15 at 19:53