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.
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 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.