I don’t know if I can get it working, at least in a sustainable fashion, but going to give it a try. I haven’t been able to figure out a way to stream the animation from a buffer of somesort in memory (at least not yet). Don’t know what kind of memory quota I have. So, I plan to write it to a file and then redirect to a web page to display the animation or provide a link to the file and let the browser deal with it.

The problem is I have a limit on disk space for the app. So, I need to reuse the same file for each request for an animation made by a given user. A different one for each user. And, I need to delete that file completely when the user leaves the site (no easy way to determine that in an absolutely certain fashion). So, I am going to try using a session of fixed length, delete the file when the session ends. If the user is still on the site a new session will be started. Or, I will extend the session each time the user requests an animated spirograph image. Don’t know which is more workable. Well to be honest, don’t know if I can delete the file when the session ends for that matter. Hopefully there is some sort of callback available for me to use.

I really don’t want to exceed my storage quota. Nor, do I want to manually delete files at a regular interval through the app’s dashboard.

Having done a bit more research, there is no callback on session end. And, there is no reliable way to determine when user leaves or closes their browser. So, likely going to have to do something clunky. And perhaps time consuming—unless I can run it in parallel.

Generate and Display Animation

For the initial development assume one user and one file.

I started by moving two functions from my command line app into sp_app_lib.py. Specifically, pl4_init() and pl4_updt(i). The init function sets some things up for generating the animation. The updt function provides the data to be added/plotted for each animation frame. The data is retrieved from the curve x and y datasets generated for each user request. Don’t think showing those would be of any particular value.

Next I added a new route to main.py. For now the route will simply generate the animation and save it to a file. I started out by just writing the code to hopefully generate the animation. For now I am not really using the form to provide any way to change any of the curve or plotting parameters. And, since the animation just uses the basic curve type data, the get request assumes a basic image is being generated and sets up all the parameters and image data accordingly. Also needed a new matplotlib import in order to generate an animation.

from matplotlib.animation import FuncAnimation
... ...
@app.route('/spirograph/ani_basic', methods=['GET', 'POST'])
def ani_basic():
  pg_ttl = "Basic Spirograph Animation"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cxsts': g.n_whl is not None,
              'cmaps': g.clr_opt
            }
    return render_template('get_curve.html', type='basic', f_data=f_data)
  elif request.method == 'POST':
    sal.proc_curve_form(request.form, 'basic')
    t_xs, t_ys, t_shp = sal.init_curve()
    sal.t_xs, sal.t_ys = t_xs, t_ys

    ax.clear()
    if g.u_fgsz and g.fig_sz > 8:
      ax.margins(0.1)
    # ax.patch.set_alpha(0.01)
    g.rcm, g.cycle = sal.set_clr_map(ax, g.lc_frq, g.rcm)

    sal.ln_sp, = ax.plot([], [])

    ani = FuncAnimation(fig, sal.pl4_updt, range(g.t_pts), init_func=sal.pl4_init, interval=.25, repeat=False)
    ax.text(0.5, 0.01, "© koach (barkncats) 2022", ha="center", transform=ax.transAxes,
            fontsize=10, alpha=.3, c=g.cycle[len(g.cycle)//2])
    ax.autoscale()

    bax2, _ = sal.set_bg(ax)

    # convert image to base64 for embedding in html page
    data = sal.fig_2_base64(fig)

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)
    p_data = {}

    return render_template('disp_image.html', sp_img=data, type='basic', c_data=c_data, i_data=i_data, p_data=p_data)

That did not work so well. Issues with my new/copied functions using what were globally accessible variables in their original module. Those variables do not currently exist in sp_app_lib.py’s context. So, I added the following above the two new functions. And, modified the route code to update them in a timely fashion. Not the best solution, but for now I just want to get the animation working.

t_xs, t_ys = [], []
ax = None

The default for the spirograph curves is 1024 data points. The way the functions work, I was using the same number of frames in the animation. That was taking quite sometime to generate the animation. So I modified the route to set the curve to use 512 data points and an equivalent number of video frames. (I have since been thinking about another approach.) The route code now looks like the following.

@app.route('/spirograph/ani_basic', methods=['GET', 'POST'])
def ani_basic():
  global ax
  pg_ttl = "Basic Spirograph Animation"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cxsts': g.n_whl is not None,
              'cmaps': g.clr_opt
            }
    return render_template('get_curve.html', type='ani_basic', f_data=f_data)
  elif request.method == 'POST':
    # app.logger.info(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")
    # print(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")

    sal.proc_curve_form(request.form, 'ani_basic')

    tmp_p = g.t_pts
    g.t_pts = 512
    g.rds = np.linspace(0, 2*np.pi, g.t_pts)

    t_xs, t_ys, t_shp = sal.init_curve()
    sal.t_xs, sal.t_ys = t_xs, t_ys

    ax.clear()
    if g.u_fgsz and g.fig_sz > 8:
      ax.margins(0.1)
    # ax.patch.set_alpha(0.01)
    g.rcm, g.cycle = sal.set_clr_map(ax, g.lc_frq, g.rcm)

    sal.ln_sp, = ax.plot([], [])
    sal.ax = ax

    print(f"g.t_pts: {g.t_pts}, len(t_xs[-1]): {len(t_xs[-1])}, len(t_ys[-1]): {len(t_ys[-1])}")

    ani = FuncAnimation(fig, sal.pl4_updt, range(g.t_pts), init_func=sal.pl4_init, interval=.25, repeat=False)
    ax.text(0.5, 0.01, "© koach (barkncats) 2022", ha="center", transform=ax.transAxes,
            fontsize=10, alpha=.3, c=g.cycle[len(g.cycle)//2])
    ax.autoscale()

    bax2, _ = sal.set_bg(ax)

    g.t_pts = tmp_p
    g.rds = np.linspace(0, 2*np.pi, g.t_pts)

    # convert image to base64 for embedding in html page
    data = sal.fig_2_base64(fig)

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)
    p_data = {}

    return render_template('disp_image.html', sp_img=data, type='basic', c_data=c_data, i_data=i_data, p_data=p_data)

And, when I attempt generate the animation, the disp_image page shows me an image with only the background. But, no more errors. There is no spirograph curve displayed on the image because the animation is never in fact generated. The ani variable has the needed data but matplotlib was never requested to produce the image (show, save, etc.). So let’s save the image to a file.

We will need another new matplotlib import and one for the pathlib module. Then at a suitable point in the route code I added the code to actually save the animation to a file in the apps static directory.

import base64, math, pathlib
... ...
from matplotlib.animation import FuncAnimation, FFMpegWriter
... ...
    uv_nm = "usr_a.mp4"
    f_dir = pathlib.Path("static")
    f_pth = f_dir.joinpath(uv_nm)
    writervideo = FFMpegWriter(fps=30)
    ani.save(f_pth, writer=writervideo)

    # convert image to base64 for embedding in html page
    data = sal.fig_2_base64(fig)

And, now when I request the animation, the image display page shows the last frame of the animation. I.E. the basic spirograph image that would otherwise be produced. Now, the next step.

Display the Saved Video

The video file is now in the app’s static directory. I can access it and get it displayed via the operating system. Now, we need a new template for displaying videos. I guess I could add that with suitable conditionals to the the disp_image.html template. But seemed to me another template might not hurt. So, I copied disp_image.html to disp_ani.html (in the templates directory). Then modified it accordingly—I chose to use an <iframe> tag rather than a <video> tag.

## the following
    <img src='data:image/png;base64,{{ sp_img }}'/>
## was replaced by
    <iframe src='/static/{{ i_data["video_nm"] }}' frameborder='0' 
    allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in- 
    picture' allowfullscreen style="width:720px; height: 720px;"></iframe>

And the route code was modified to pass the file name to the new template.

... ...
    i_data["video_nm"] = uv_nm
... ...
    return render_template('disp_ani.html', type='basic', c_data=c_data, i_data=i_data, p_data=p_data)

And that appears to work. You will have to trust me, as I am not going to show you any screen captures. Don’t really know how to capture a video of a video. (Going to take a break. Might be done, might not; time will tell.)

Browser Caching in IFrame

It took me awhile but I finally realized that the video file (given the exact same name) in the iframe was being cached by the browser. Hitting the Draw another one! button did generate a new image and updated the video file. But the browser would display the cached file, not the new one. Didn’t initially notice because I was changing the code and restarting so frequently the issue seldom came up. But, once I settled on some things and started using the button, things weren’t going as I expected.

I tried setting cache control in the HTML header. No go. Tried setting something similar in the Flask app config. Again no go. In the end I resorted to the tried and true. Add a new querystring to the file url on each request. I decided to use the current value of time.time() to generate a new value at the time the url is being sent to the display page. The relevant bit is shown below.

    dttm = time.time()
    secs = int(dttm % 60)
    mins = str(int(dttm // 60))[-2:]
    hrs = str(int(dttm // 60 // 60))[-2:]

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)
    i_data["video_nm"] = f"{uv_nm}?v={hrs}{mins}{secs}"

And that seems to work.

The three last urls looked like this:

  • /static/usr_a_129.mp4?v=397912
  • /static/usr_a_129.mp4?v=397955
  • /static/usr_a_129.mp4?v=398029

Refactor Handling of Frame Count and Interval

It occurred to me that I had an option regarding the relationship between the number of frames in the animation and the number of datapoints. Until now I had pretty much been assuming both had to be the same. But, given how the animation update function works that is not true. I could, in fact, just plot more points in each animation frame. And, that’s what I decided to do. I like to use at least 1024 datapoints for the spirographs so that there is some smoothness to the curves. In some cases many more are needed but 1024 is a decent compromise.

I decided I’d add a ratio variable that would determine the number of frames based on the number of datapoints. It would basically specify how many datapoints would be added to each animation frame. And, it would also be used as a multiplier for a base frame interval value. I figured, the more points being plotted, the slower each frame should change to get the same sense of overall animation speed.

Note: I thought the animations were a bit fast, so I have also increased that base interval value from .25 to 50 milliseconds.

By varying that ratio I sort of zeroed in on a good compromise for the ratio based on the overall look and time to save the .mp4. I am sure that the number of wheels and other image parameters may affect how long it takes to save the animation to a file. But, I am also pretty sure the number frames in the video is the biggest factor. So am proceeding accordingly.

The number of frames would also affect the file size, but I was a little less concerned about that at this point. The time the user had to wait was something I wanted to reduce as much as possible and still have a decent animation.

To start I removed the code to change and return the g.t_pts value before and after generating the curve and animation. Then added/modified the following.

    v_frm_ratio = 8
    if v_frm_ratio == 1:
      frames = range(0, g.t_pts)
    else:
      # after applying the step, sometimes the full curve was not generated in the animation, so increment t_pts
      frames = range(0, g.t_pts + 1, v_frm_ratio)
    nbr_f = len(frames)
    v_ntrvl = 50 * v_frm_ratio

    ani = FuncAnimation(fig, sal.pl4_updt, frames, init_func=sal.pl4_init, interval=v_ntrvl, repeat=False)

It seems the interval value only controls the rate of animation if it is displayed by matplotlib. The rate of animation in the mp4 is controlled by the fps parameter passed to FFMpegWriter(). So we need to adjust that as well. The value I am generating is just a guess based on a couple of tests. But seems to work for the different numbers of frames I have been playing with.

    v_ntrvl = 50 * v_frm_ratio
    mpg_fps = 30
    if nbr_f < 254:
      mpg_fps = 12
...    
    writervideo = FFMpegWriter(fps=mpg_fps)

Timings

The reason I used time.time() to generate the changing value for the video url was because I was already using it to measure the time being used by various sections of code in the process of generating the animation and saving it to a file. It should be pretty clear that the save to file would be the biggest time consumer. And, the more frames in the animation the longer it would take to generate the mp4.

I won’t show the code for the timing effort, but will say I used time.perf_counter() to get better resolution.

Here’s some examples.

Animation
    Frames: 1024
    Frame interval: 50 milliseconds
    Timing:
        Generate curve data: 0.0193 seconds
        Generate animation: 0.0805 seconds
        Save animation to file: 125.5630 seconds
        More or less total time prior to render: 125.9501 seconds
Animation
    Frames: 513
    Frame interval: 100 milliseconds
    Timing:
        Generate curve data: 0.0333 seconds
        Generate animation: 0.0813 seconds
        Save animation to file: 62.4472 seconds
        More or less total time prior to render: 62.7923 seconds
Animation
    Frames: 257
    Frame interval: 200 milliseconds
    Timing:
        Generate curve data: 0.0057 seconds
        Generate animation: 0.0812 seconds
        Save animation to file: 31.6891 seconds
        More or less total time prior to render: 31.9077 seconds
Animation
    Frames: 129
    Frame interval: 400 milliseconds
    Timing:
        Generate curve data: 0.0046 seconds
        Generate animation: 0.0808 seconds
        Save animation to file: 15.9695 seconds
        More or less total time prior to render: 16.2511 seconds

The thing was that the 128 frame tests, at a suitable fps, looked pretty much as good (in the browser) as the 1024 frame tests. I don’t know about you, but I would sure prefer to wait 16 seconds rather than 2 minutes to view the animation.

Examples

I am not going to include the example videos in the post. But I will include the curve/animation data with a link to that video file.

1024 Frames

Love this one!

Curve Parameters
    Wheels: 11 wheels (shape(s): circle (c))
    Symmetry: k_fold = 10, congruency = 9
    Frequencies: [-1, -1, 9, 9, 49, -31, 9, 29, 9, 39, 29]
    Widths: [1, 0.629373499595326, 0.3710495698172404, 0.36651250955815284, 0.2486832622351453, 0.23380642757962283, 0.125, 0.125j, 0.125, 0.125j, 0.125]
    Heights: [1, 0.5806598888709655, 0.49276854191885044, 0.290835522410666, 0.2716889954440199, 0.25345896446466554, 0.21561301183144455, 0.125j, 0.125j, 0.125, 0.125j]
    Data points: 1024

Drawing Parameters
    Colour map: magma
    BG colour map: bone
    Line width (if used): 5
    Image size: 8
    Image DPI: 72

Animation
    Frames: 1024
    Frame interval: 50 milliseconds
    Timing:
        Generate curve data: 0.0193 seconds
        Generate animation: 0.0805 seconds
        Save animation to file: 125.5630 seconds
        More or less total time prior to render: 125.9501 seconds

usr_a_1024.mp4

512+1 Frames

Curve Parameters
    Wheels: 8 wheels (shape(s): rhombus (r))
    Symmetry: k_fold = 2, congruency = 1
    Frequencies: [3, -7, 9, -3, 7, -3, 5, 5]
    Widths: [1, 0.5711255133786276, 0.5127620736012366j, 0.49759255902882576j, 0.4321696657877219j, 0.22936860093068845, 0.15606905773670277j, 0.1524574768824626j]
    Heights: [1, 0.6086228050874881j, 0.47219708884333916j, 0.26310056230844353, 0.15499684954896295, 0.125, 0.125j, 0.125]
    Data points: 1024

Drawing Parameters
    Colour map: YlGnBu
    BG colour map: viridis
    Line width (if used): 8
    Image size: 8
    Image DPI: 72

Animation
    Frames: 513
    Frame interval: 100 milliseconds
    Timing:
        Generate curve data: 0.0333 seconds
        Generate animation: 0.0813 seconds
        Save animation to file: 62.4472 seconds
        More or less total time prior to render: 62.7923 seconds

usr_a_513.mp4

256+1 Frames

Curve Parameters
    Wheels: 6 wheels (shape(s): ellipse (e))
    Symmetry: k_fold = 3, congruency = 2
    Frequencies: [2, 5, 2, 8, 14, 5]
    Widths: [1, 0.7238818140684568, 0.5342837621261827, 0.3140260849861155j, 0.28439471046517883, 0.242651981993744]
    Heights: [1, 0.5003925979389627j, 0.2609297605652754, 0.22249605757472024, 0.14050799658002258j, 0.125j]
    Data points: 1024

Drawing Parameters
    Colour map: turbo
    BG colour map: rainbow
    Line width (if used): 6
    Image size: 8
    Image DPI: 72

Animation
    Frames: 257
    Frame interval: 200 milliseconds
    Timing:
        Generate curve data: 0.0057 seconds
        Generate animation: 0.0812 seconds
        Save animation to file: 31.6891 seconds
        More or less total time prior to render: 31.9077 seconds

usr_a_257.mp4

128+1 Frames

The first is the overly fast version generated before I modified the fps value used to save the animation to file.

Curve Parameters
    Wheels: 9 wheels (shape(s): circle (c))
    Symmetry: k_fold = 9, congruency = 6
    Frequencies: [6, -3, 33, 15, 6, 33, -12, -3, 15]
    Widths: [1, 0.530750948292877, 0.45221077253262226j, 0.23077197241979105j, 0.1371837373255999, 0.125, 0.125, 0.125, 0.125]
    Heights: [1, 0.5179324925779726, 0.48665375148050566j, 0.3396550372272263, 0.23070315036361363j, 0.13953480768463036, 0.125, 0.125, 0.125j]
    Data points: 1024

Drawing Parameters
    Colour map: BuGn
    BG colour map: bone
    Line width (if used): 6
    Image size: 8
    Image DPI: 72

Animation
    Frames: 129
    Frame interval: 400 milliseconds
    Timing:
        Generate curve data: 0.0076 seconds
        Generate animation: 0.1049 seconds
        Save animation to file: 14.2520 seconds
        More or less total time prior to render: 14.5054 seconds

usr_a_129.mp4

And this one is after the fps rate was reduced for videos with a smallish number of frames.

Curve Parameters
    Wheels: 9 wheels (shape(s): circle (c))
    Symmetry: k_fold = 4, congruency = 3
    Frequencies: [3, -1, -13, -1, -9, 7, 15, 7, -9]
    Widths: [1, 0.7360801367542753, 0.37639244097595576j, 0.22468890288250953j, 0.1416922208496098, 0.125, 0.125, 0.125j, 0.125]
    Heights: [1, 0.6693274563864389, 0.4617811085281635, 0.3991005473089781, 0.22829957214588772, 0.18531997975672246j, 0.1379457406183376j, 0.125j, 0.125j]
    Data points: 1024

Drawing Parameters
    Colour map: gnuplot
    BG colour map: cividis
    Line width (if used): 4
    Image size: 8
    Image DPI: 72

Animation
    Frames: 129
    Frame interval: 400 milliseconds
    Timing:
        Generate curve data: 0.0046 seconds
        Generate animation: 0.0808 seconds
        Save animation to file: 15.9695 seconds
        More or less total time prior to render: 16.2511 seconds

usr_a_129_2.mp4

Done

This is getting to be a rather lengthy post. And, I haven’t yet tackled what I expect to be the hard stuff. That should best be left for another post or two.

So, until next time, may your fingers tap a happy rhythm.