Build a Gradio Contact Sheet for Clips From a Free AI Video Generator A tutorial published with the headline "Build a Gradio Contact Sheet for Clips From a Free AI Video Generator" walks through a local Python app that extracts the first, middle and last frames of a video and renders them as a downloadable PNG contact sheet. The script runs on Gradio 5.49.1, PyAV 16.0.1 and Pillow 11.3.0 in Python 3.12, and enforces limits of 20 MiB per file, 900 decoded frames, 1920-pixel maximum edge length and a 20-second maximum video span. The app uses two decode passes, labels the midpoint with the actual decoded frame time rather than the requested time, and sets the opening frame to zero. Two versions of a five-second clip can look similar in the player. Put their opening, midpoint, and ending on a contact sheet and a reviewer can see where a subject leaves the frame or where the background changes. The sheet also gives people an exact place to begin a second viewing. This local Gradio app takes a video file and returns a preview, a downloadable PNG, and the decoded frame times. I used a synthetic clip of a red marker moving along a track to check the app. That fixture makes the expected changes obvious, including when the last frame should be different from the first. Start with the first frame that has a presentation timestamp. Find the last timestamp in the same video track, then pick the decoded frame nearest the point halfway between them. The midpoint label records the frame you actually got. A request for 2.458 seconds might land on a frame at 2.500 seconds. The app uses PyAV’s frame presentation times and sets the opening frame to zero. The ending label is the time at which the final frame starts. For a five-second clip, that timestamp can be earlier than five seconds because the frame stays visible until playback ends. A one-frame clip repeats the same image and displays zero under all three panels. There are two decode passes. The first keeps timestamp and frame-number pairs. The second converts only the selected frames into images. Each image fits inside the same panel without stretching, so you can compare a portrait clip with another portrait clip. Phone videos that rely on container rotation metadata may need to be normalized before using this sample. In a Python 3.12 environment, install the versions used for the check and save the script as contact sheet.py . python -m pip install gradio==5.49.1 av==16.0.1 pillow==11.3.0 python contact sheet.py Open the local address printed by Gradio. Select a clip and click Build contact sheet . The File component passes the uploaded file path to the callback and gives the PNG back as a download. Keeping the example on loopback limits it to your machine. Inputs and outputs can remain in temporary storage, so use non-sensitive files for this demo and read Gradio’s file access guide before hosting it for other people. python from pathlib import Path from tempfile import mkdtemp import av import gradio as gr from PIL import Image, ImageDraw, ImageOps MAX BYTES = 20 1024 1024 MAX FRAMES = 900 MAX EDGE = 1920 MAX SPAN = 20 THUMBNAIL = 320, 180 def video frames container : if not container.streams.video: raise ValueError "Choose a file with a video track." stream = container.streams.video 0 for index, frame in enumerate container.decode stream : if index = MAX FRAMES: raise ValueError "Choose a clip with at most 900 decoded frames." if max frame.width, frame.height MAX EDGE: raise ValueError "Choose a clip with edges no longer than 1920 pixels." yield index, frame def make contact sheet path, output : if Path path .stat .st size MAX BYTES: raise ValueError "Choose a file of 20 MiB or less." timeline = with av.open path as container: for index, frame in video frames container : if frame.time is None: continue start = timeline 0 1 if timeline else frame.time if abs frame.time - start MAX SPAN: raise ValueError "Choose a clip with a video span of 20 seconds or less." timeline.append index, frame.time if not timeline: raise ValueError "No decoded video frame had a presentation timestamp." timeline.sort key=lambda item: item 1 start = timeline 0 1 end = timeline -1 1 middle = min timeline, key=lambda item: abs item 1 - start + end / 2 picks = timeline 0 , middle, timeline -1 wanted = {index for index, in picks} images = {} with av.open path as container: for index, frame in video frames container : if index in wanted: images index = ImageOps.contain frame.to image , THUMBNAIL if images.keys == wanted: break width, height = THUMBNAIL gap = 20 sheet = Image.new "RGB", 3 width + 4 gap, height + 76 , " f8f2ec" draw = ImageDraw.Draw sheet labels = for column, name, index, seconds in enumerate zip "Opening", "Midpoint", "Ending" , picks : image = images index x = gap + column width + gap draw.rectangle x, 20, x + width - 1, height + 19 , fill=" 34223f" sheet.paste image, x + width - image.width // 2, 20 + height - image.height // 2 label = f"{name} {seconds - start:.3f} s" draw.text x, height + 36 , label, fill=" 34223f", font size=19 labels.append label sheet.save output return labels def inspect clip path : if not path: return None, None, "Choose a local video file." output = Path mkdtemp prefix="contact-sheet-" / "contact-sheet.png" try: labels = make contact sheet path, output except av.FFmpegError, OSError : return None, None, "Could not read the clip. Choose a playable video file." except ValueError as error: return None, None, str error return str output , str output , " | ".join labels with gr.Blocks as demo: gr.Markdown " Three-frame clip review" source = gr.File label="Local clip", type="filepath" submit = gr.Button "Build contact sheet" preview = gr.Image label="Contact sheet", interactive=False download = gr.File label="Download PNG" status = gr.Textbox label="Frame times or error", interactive=False submit.click inspect clip, source, preview, download, status , api name="inspect" if name == " main ": demo.launch server name="127.0.0.1", share=False, max file size="20mb" I ran the Gradio endpoint with these files on macOS using the versions above. The moving-marker clip contains 60 frames at 12 frames per second. The one-frame file contains a single frame. The invalid file contains plain text with an .mp4 extension. | Input | Result | |---|---| | Five-second moving marker | PNG created. Opening at 0.000 s, midpoint at 2.500 s, ending at 4.917 s. | | Text named invalid.mp4 | The app asks for a playable video and clears the preview and download fields. | | One-frame MP4 | PNG created. All three panels show the same frame at 0.000 s. | This contact sheet comes from the synthetic moving-marker clip. Each label gives the decoded time relative to the first frame. Use the one-frame result to see what happens when the same decoded frame is nearest to all three requested positions. The one-frame fixture gives the same still and timestamp in all three panels. A fourth check is a video with a brief change between the three sample times. Watch the clip after opening the sheet and see what the stills missed. For review notes, you can say “the marker reaches the edge after the midpoint” and then use the player to check the exact motion and audio. The three stills give reviewers a common reference, especially when they watch at different times. For a replaceable sample, the Flaq AI free AI video generator https://flaq.ai/free-ai-video-generator/ page lists five-second 480p previews. Start with a prompt that describes one visible action. Try “Fixed overhead camera. A coral marker slides left to right along a ruler on a cream desk.” Generate a second clip after changing one detail, then build a contact sheet for each. Keep the action and camera direction steady if you want the second sheet to help explain a particular change. This guide is by the Flaq AI team and references our own tool.