{"slug": "build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator", "title": "Build a Gradio Contact Sheet for Clips From a Free AI Video Generator", "summary": "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.", "body_md": "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.\n\nThis 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.\n\nStart 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.\n\nThe 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.\n\nThere 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.\n\nIn a Python 3.12 environment, install the versions used for the check and save the script as `contact_sheet.py`.\n\n```\npython -m pip install gradio==5.49.1 av==16.0.1 pillow==11.3.0\npython contact_sheet.py\n```\n\nOpen 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.\n\n``` python\nfrom pathlib import Path\nfrom tempfile import mkdtemp\n\nimport av\nimport gradio as gr\nfrom PIL import Image, ImageDraw, ImageOps\n\nMAX_BYTES = 20 * 1024 * 1024\nMAX_FRAMES = 900\nMAX_EDGE = 1920\nMAX_SPAN = 20\nTHUMBNAIL = (320, 180)\n\ndef video_frames(container):\n    if not container.streams.video:\n        raise ValueError(\"Choose a file with a video track.\")\n    stream = container.streams.video[0]\n    for index, frame in enumerate(container.decode(stream)):\n        if index >= MAX_FRAMES:\n            raise ValueError(\"Choose a clip with at most 900 decoded frames.\")\n        if max(frame.width, frame.height) > MAX_EDGE:\n            raise ValueError(\"Choose a clip with edges no longer than 1920 pixels.\")\n        yield index, frame\n\ndef make_contact_sheet(path, output):\n    if Path(path).stat().st_size > MAX_BYTES:\n        raise ValueError(\"Choose a file of 20 MiB or less.\")\n\n    timeline = []\n    with av.open(path) as container:\n        for index, frame in video_frames(container):\n            if frame.time is None:\n                continue\n            start = timeline[0][1] if timeline else frame.time\n            if abs(frame.time - start) > MAX_SPAN:\n                raise ValueError(\"Choose a clip with a video span of 20 seconds or less.\")\n            timeline.append((index, frame.time))\n    if not timeline:\n        raise ValueError(\"No decoded video frame had a presentation timestamp.\")\n\n    timeline.sort(key=lambda item: item[1])\n    start = timeline[0][1]\n    end = timeline[-1][1]\n    middle = min(timeline, key=lambda item: abs(item[1] - (start + end) / 2))\n    picks = [timeline[0], middle, timeline[-1]]\n\n    wanted = {index for index, _ in picks}\n    images = {}\n    with av.open(path) as container:\n        for index, frame in video_frames(container):\n            if index in wanted:\n                images[index] = ImageOps.contain(frame.to_image(), THUMBNAIL)\n                if images.keys() == wanted:\n                    break\n\n    width, height = THUMBNAIL\n    gap = 20\n    sheet = Image.new(\"RGB\", (3 * width + 4 * gap, height + 76), \"#f8f2ec\")\n    draw = ImageDraw.Draw(sheet)\n    labels = []\n    for column, (name, (index, seconds)) in enumerate(\n        zip((\"Opening\", \"Midpoint\", \"Ending\"), picks)\n    ):\n        image = images[index]\n        x = gap + column * (width + gap)\n        draw.rectangle((x, 20, x + width - 1, height + 19), fill=\"#34223f\")\n        sheet.paste(image, (x + (width - image.width) // 2, 20 + (height - image.height) // 2))\n        label = f\"{name}  {seconds - start:.3f} s\"\n        draw.text((x, height + 36), label, fill=\"#34223f\", font_size=19)\n        labels.append(label)\n    sheet.save(output)\n    return labels\n\ndef inspect_clip(path):\n    if not path:\n        return None, None, \"Choose a local video file.\"\n    output = Path(mkdtemp(prefix=\"contact-sheet-\")) / \"contact-sheet.png\"\n    try:\n        labels = make_contact_sheet(path, output)\n    except (av.FFmpegError, OSError):\n        return None, None, \"Could not read the clip. Choose a playable video file.\"\n    except ValueError as error:\n        return None, None, str(error)\n    return str(output), str(output), \" | \".join(labels)\n\nwith gr.Blocks() as demo:\n    gr.Markdown(\"## Three-frame clip review\")\n    source = gr.File(label=\"Local clip\", type=\"filepath\")\n    submit = gr.Button(\"Build contact sheet\")\n    preview = gr.Image(label=\"Contact sheet\", interactive=False)\n    download = gr.File(label=\"Download PNG\")\n    status = gr.Textbox(label=\"Frame times or error\", interactive=False)\n    submit.click(inspect_clip, source, [preview, download, status], api_name=\"inspect\")\n\nif __name__ == \"__main__\":\n    demo.launch(server_name=\"127.0.0.1\", share=False, max_file_size=\"20mb\")\n```\n\nI 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.\n\n| Input | Result | \n|---|---|\n| Five-second moving marker | PNG created. Opening at 0.000 s, midpoint at 2.500 s, ending at 4.917 s. | \n| Text named `invalid.mp4` | The app asks for a playable video and clears the preview and download fields. | \n| One-frame MP4 | PNG created. All three panels show the same frame at 0.000 s. | \n\nThis contact sheet comes from the synthetic moving-marker clip. Each label gives the decoded time relative to the first frame.\n\nUse the one-frame result to see what happens when the same decoded frame is nearest to all three requested positions.\n\nThe one-frame fixture gives the same still and timestamp in all three panels.\n\nA 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.\n\nFor 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.\n\nThis guide is by the Flaq AI team and references our own tool.", "url": "https://wpnews.pro/news/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator", "canonical_source": "https://discuss.huggingface.co/t/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator/191765#post_1", "published_at": "2026-11-12 07:13:35+00:00", "updated_at": "2026-11-12 07:20:38.870393+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai"], "entities": ["Gradio", "PyAV", "Pillow", "Python 3.12"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator", "markdown": "https://wpnews.pro/news/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator.md", "text": "https://wpnews.pro/news/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator.txt", "jsonld": "https://wpnews.pro/news/build-a-gradio-contact-sheet-for-clips-from-a-free-ai-video-generator.jsonld"}}