DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

Reject Animated Files Before Sending AI Images Through a Still-Image Pipeline

A thumbnail worker can silently flatten an animated file when it opens the image, converts it and saves frame zero. If your contract says “one still image,” make that contract explicit before conversion. A .jpg filename is not enough: it can contain PNG, GIF or WebP bytes.

This post implements a narrow Pillow gate, tests the failure paths and runs it against two actual public image files. It does not implement a complete secure upload service or assert that an AI model produces animation.

Define the acceptance contract

Accept only JPEG, PNG, WebP or GIF files that Pillow recognizes as exactly one frame and can decode. Reject other formats, multiple frames and decoder failures. A single-frame GIF is accepted under this policy; change the allowlist if your application forbids GIF entirely.

Pillow’s Image reference documents n_frames and is_animated, including the need for defaults when a plugin does not define those attributes. The APNG format documentation explains an additional wrinkle: a separate default image can count as a frame without belonging to the animation. The gate does not need to choose which frame to extract—it rejects the multi-frame file.

Implement the gate before conversion

Save this as still_gate.py. The explicit load() forces decoding while the file remains open. A successful result describes the bytes Pillow read, not the extension.

from pathlib import Path
from PIL import Image

ALLOWED = {'JPEG', 'PNG', 'WEBP', 'GIF'}

def inspect_still(path):
    with Image.open(path) as im:
        if im.format not in ALLOWED:
            raise ValueError('unsupported_format')
        frames = getattr(im, 'n_frames', 1)
        if frames != 1 or getattr(im, 'is_animated', False):
            raise ValueError('multiple_frames')
        im.load()  # Force decoding before accepting the file.
        return {'format': im.format, 'frames': frames,
                'width': im.width, 'height': im.height}

if __name__ == '__main__':
    import json, sys
    for name in sys.argv[1:]:
        try:
            print(json.dumps({'file': Path(name).name, 'ok': True,
                              **inspect_still(name)}))
        except (OSError, ValueError, EOFError) as exc:
            print(json.dumps({'file': Path(name).name, 'ok': False,
                              'error': str(exc)}))
            sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

The CLI exits nonzero at the first rejected file. In an upload worker, catch those errors at the job boundary, return a validation response and do not enqueue the thumbnail step. Keep the original bytes unchanged until acceptance; otherwise your conversion may erase the evidence of animation before the gate sees it.

This narrow check is not an authenticity, visual-quality or malware guarantee. A production decoder should also run with your normal size limits, timeouts and isolation. Do not treat this function as a substitute for those controls.

Reproduce the tests

The following tests create small, deterministic local fixtures. Red and blue frames differ deliberately so an encoder cannot collapse identical frames into a single still. Their misleading .jpg filename verifies that the decision follows decoded format and frame count.

import tempfile, unittest
from pathlib import Path
from PIL import Image
from still_gate import inspect_still

class GateTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.a = Image.new('RGB', (16, 16), 'red')
        self.b = Image.new('RGB', (16, 16), 'blue')
    def tearDown(self): self.tmp.cleanup()
    def test_static_formats(self):
        for fmt in ('JPEG', 'PNG', 'WEBP', 'GIF'):
            with self.subTest(fmt=fmt):
                p = self.root / 'image.dat'
                self.a.save(p, format=fmt)
                self.assertEqual(inspect_still(p)['frames'], 1)
    def test_animated_formats(self):
        for fmt in ('GIF', 'PNG', 'WEBP'):
            with self.subTest(fmt=fmt):
                p = self.root / 'looks-like-a-still.jpg'
                self.a.save(p, format=fmt, save_all=True,
                            append_images=[self.b], duration=100, loop=0)
                with self.assertRaisesRegex(ValueError, 'multiple_frames'):
                    inspect_still(p)
    def test_apng_default_image(self):
        p = self.root / 'default.png'
        self.a.save(p, format='PNG', save_all=True, default_image=True,
                    append_images=[self.b, self.a], duration=100, loop=0)
        with self.assertRaisesRegex(ValueError, 'multiple_frames'):
            inspect_still(p)
    def test_unsupported(self):
        p = self.root / 'image.bmp'; self.a.save(p)
        with self.assertRaisesRegex(ValueError, 'unsupported_format'):
            inspect_still(p)
    def test_corrupt(self):
        p = self.root / 'broken.png'; p.write_bytes(b'not an image')
        with self.assertRaises(OSError): inspect_still(p)

if __name__ == '__main__': unittest.main()
Enter fullscreen mode Exit fullscreen mode

Run in a fresh environment with the tested dependency version:

python -m pip install Pillow==12.3.0
python -m unittest -v test_still_gate.py
Enter fullscreen mode Exit fullscreen mode

The September 21 run passed all five test methods. The parameterized methods cover four static formats and three animated formats; the remaining tests cover APNG’s separate default image, unsupported BMP and corrupt bytes. If your Pillow build lacks a required codec, let the test fail and resolve that deployment mismatch rather than silently skipping the format.

Inspect actual assets, not just fixtures

The first real input is an existing public FLUX example: a coral retail-poster concept. It is useful here as a normal JPEG passing case, not as proof that its generated lettering is correct.

Actual public coral poster JPEG used as the first single-frame passing input.

The second is a separate public serum-bottle concept. Its packaging is illustrative, not verified product information. Both files below are the original public JPEG bytes, not screenshots of an empty generator.

Actual public serum packshot JPEG used as the second single-frame passing input.

I saved these as cover.jpg and workspace.jpg, then ran:

python still_gate.py cover.jpg workspace.jpg
Enter fullscreen mode Exit fullscreen mode

Actual output:

{"file": "cover.jpg", "ok": true, "format": "JPEG", "frames": 1, "width": 1024, "height": 1024}
{"file": "workspace.jpg", "ok": true, "format": "JPEG", "frames": 1, "width": 1024, "height": 1024}
Enter fullscreen mode Exit fullscreen mode

Passing means these files satisfy the single-frame contract. It does not mean their text, anatomy, branding or licensing has passed any other review.

Where generation fits in the workflow

For context, the examples are publicly available from the FLUX Dev workspace. The current September 21 form shows FLUX Dev, Prompt, optional Reference images, aspect ratio and Output count. The inspected selection was 1:1 and one output, quoted at 9 credits with Public · watermarked. Advanced Settings exposed seed, prompt guidance, generation steps, output format, Go fast, megapixels, output quality and prompt strength. The visible guidance was 3, steps 28 and quality 80; these are current form values, not inferred settings for the older examples.

To prepare a new source, enter your prompt, inspect the chosen format and quote, and use Generate only with sign-in and sufficient credits. Uploading references requires sign-in. No generation was run for this article; the code works equally well on authorized images from another source.

Apply the gate to the downloaded bytes before your own conversion or resize stage. If it rejects a file, decide explicitly whether your product should support animation through a separate pipeline or return a clear validation error. Do not silently select the first frame.

Once the checks pass in your deployed environment, you can use another authorized still as a manual integration case. Keep fixture tests in CI and real-file observations in your test notes; they answer different questions.

Top comments (0)