Character animation from a still image has a simple contract: one image says what the character looks like, one video says how it moves. Most failed attempts are not about the model at all. The driving clip is too long, the aspect ratio does not match the character, or the file breaks a size rule the upload form will reject anyway.
This post walks through a small Python script that checks both inputs against the published rules and prints the exact ffmpeg commands that fix what it finds. Everything below was run locally on a synthetic character image and an ffmpeg test pattern. No generation job was submitted.

Diagram made for this article from the rules on the page.
The rules worth encoding
The Wan Animate 2 page lists the input requirements in its upload panel and step-by-step section:
- Character image: one JPG, JPEG or PNG, up to 10 MB, both sides greater than 340 px, aspect ratio between 2:5 and 5:2.
- Driving video: one MP4 or MOV, up to 100 MB, both sides greater than 340 px, same aspect range.
- Duration depends on orientation: following the video's orientation accepts 3–30 second clips; following the image's orientation accepts 3–10 seconds.
- Other controls: background from the image or the video, 720p or 1080p output, and an optional prompt of up to 2,500 characters.
The orientation rule is the one people miss. It changes the allowed clip length, so the checker takes it as an argument instead of assuming one value.

Diagram made for this article.
The script
It needs Pillow and ffprobe/ffmpeg on your PATH. Save it as animate_prep.py:
#!/usr/bin/env python3
"""animate_prep.py - check a character image + driving video against the
input rules published on the Wan Animate 2 page, and print ffmpeg fixes.
Rules (edit when the page changes):
image: JPG/JPEG/PNG, <= 10 MB, both sides > 340 px, aspect 2:5..5:2
video: MP4/MOV, <= 100 MB, both sides > 340 px, aspect 2:5..5:2
3-30 s when orientation follows the video, 3-10 s when it follows the image
Needs Pillow and ffprobe/ffmpeg on PATH. Nothing is uploaded.
"""
import argparse, json, os, subprocess, sys
from PIL import Image
MB = 1024 * 1024
IMG_EXT, VID_EXT = {".jpg", ".jpeg", ".png"}, {".mp4", ".mov"}
MIN_SIDE, AR_MIN, AR_MAX = 340, 2 / 5, 5 / 2
DUR = {"video": (3.0, 30.0), "image": (3.0, 10.0)}
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height:format=duration",
"-of", "json", path], capture_output=True, text=True, check=True).stdout
j = json.loads(out)
s = j["streams"][0]
return s["width"], s["height"], float(j["format"]["duration"])
def check_dims(tag, w, h, errs):
if min(w, h) <= MIN_SIDE:
errs.append(f"{tag}: {w}x{h} - both sides must be > {MIN_SIDE} px")
if not AR_MIN <= w / h <= AR_MAX:
errs.append(f"{tag}: aspect {w / h:.2f} outside 2:5..5:2")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image"); ap.add_argument("video")
ap.add_argument("--orientation", choices=["video", "image"], default="image")
ap.add_argument("--start", type=float, default=0.0, help="where the useful motion begins (s)")
a = ap.parse_args()
errs, fixes = [], []
ext = os.path.splitext(a.image)[1].lower()
if ext not in IMG_EXT: errs.append(f"image: {ext} not in {sorted(IMG_EXT)}")
if os.path.getsize(a.image) > 10 * MB: errs.append("image: larger than 10 MB")
with Image.open(a.image) as im: iw, ih = im.size
check_dims("image", iw, ih, errs)
ext = os.path.splitext(a.video)[1].lower()
if ext not in VID_EXT: errs.append(f"video: {ext} not in {sorted(VID_EXT)}")
if os.path.getsize(a.video) > 100 * MB: errs.append("video: larger than 100 MB")
vw, vh, dur = probe(a.video)
check_dims("video", vw, vh, errs)
lo, hi = DUR[a.orientation]
if not lo <= dur <= hi:
errs.append(f"video: {dur:.1f} s outside {lo:.0f}-{hi:.0f} s for orientation={a.orientation}")
keep = min(hi, max(lo, dur - a.start))
fixes.append(f"ffmpeg -y -ss {a.start:g} -i {a.video} -t {keep:g} -c:v libx264 -an trimmed.mp4")
# match the driving clip's framing to the character image (centre crop)
ia, va = iw / ih, vw / vh
if abs(ia - va) > 0.05:
if va > ia: # video too wide -> crop width
cw, ch = int(vh * ia) // 2 * 2, vh
else: # video too tall -> crop height
cw, ch = vw, int(vw / ia) // 2 * 2
src = "trimmed.mp4" if fixes else a.video
fixes.append(f"ffmpeg -y -i {src} -vf crop={cw}:{ch} -c:v libx264 -an framed.mp4"
f" # {vw}x{vh} -> {cw}x{ch}, aspect {ia:.2f} like the image")
print(f"image {iw}x{ih} aspect {ia:.2f} | video {vw}x{vh} aspect {va:.2f} {dur:.1f} s | orientation={a.orientation}")
for e in errs: print("FAIL", e)
for f in fixes: print("FIX ", f)
if not errs and not fixes: print("PASS ready for upload")
sys.exit(1 if errs else 0)
if __name__ == "__main__":
main()
Three things it does that a plain size check does not:
- Reads real dimensions and duration with ffprobe. File names and container metadata lie; the stream does not.
- Prints fixes instead of applying them. You decide where the useful motion starts (
--start), and you can paste the command or tweak it.
- Matches the clip to the character, not just to the rules. A 16:9 clip technically passes the 2:5–5:2 range, but if your character image is a 2:3 portrait, the page itself advises matching the framing and body position of the image and the motion reference. The script suggests a centre crop to the image's aspect ratio when the two differ by more than 0.05.
Run 1: a 14-second landscape clip
The test inputs are a flat 768×1152 character drawing and a 14-second 1920×1080 testsrc2 clip, with the useful motion assumed to start at 2 seconds. With orientation following the image, the clip is too long, and its aspect ratio is far from the portrait character.

Real output from a run on 2026-10-26.
The exit code is 1, so this can gate a script or a Makefile target.
Apply the fixes
The two printed commands trim to 10 seconds from the 2-second mark, then crop the centre 720×1080 region so the clip has the same 0.67 aspect ratio as the character image.

Frames extracted with ffmpeg from the test clip during the same session.
A centre crop is only a default. If the performer stands off-centre, use crop=w:h:x:y with an explicit x offset, or reframe in an editor. What matters is that the body sits in the frame roughly where the character's body sits in the image.
Run 2: re-check

Real output from a run on 2026-10-26.
The re-encoded clip is 720×1080, exactly 10.0 seconds, and inside every published rule. That is the point to open the workspace, not before.
What the script cannot check
The page's advice on consistency is mostly visual: use a single clearly visible subject, keep the face, shoulders and torso visible, reduce occlusion and rapid cuts, and avoid large pose changes. None of that is measurable with ffprobe. Treat the script as the mechanical gate and still watch the clip once:
- Does the performer stay in frame for the whole clip?
- Are there hard cuts? A continuous take is what the page asks for.
- Is the starting pose close to the character image's pose?
It also cannot know the credit cost. The page says the quote uses the verified reference-video duration and the selected resolution, and the generator shows the required credits before submission. Trimming to the motion you need is therefore also the cheapest thing you can do.

Screenshot captured 2026-10-26 (Asia/Shanghai).
Small extensions
- Add
-vf scale=-2:1080 after the crop if a source is below 1080 px tall and you plan to request 1080p output; do not upscale tiny clips and expect detail.
- Strip audio (
-an) as shown unless you need it elsewhere; the driving clip supplies movement and timing.
- Keep the constants at the top of the file and update them when the page changes. Published limits drift, and a checker with stale numbers is worse than none.