An AI-generated illustration is useful, but a business campaign also needs readable copy, predictable timing and a deliverable file. This walkthrough turns one image and a small JSON brief into a silent, twelve-second portrait promo. Change the words and render another version without asking an image model to redraw the lettering.
The output is a 1080 by 1920 MP4 with three four-second copy cards, a persistent brand and headline, and short opening and closing fades. The artwork stays still. This is deterministic video assembly around AI artwork, not generated video motion.
Fictional example: Juniper Cycle, its campaign and service invitation are invented teaching material. The source illustration was generated with AI and inspected; it is not a photograph of a product offered for sale. No campaign was posted to a social platform, and no engagement, conversion or business results were measured.
Keep the art, words and layout separate
The bicycle artwork contains no campaign text. The renderer places it inside a 1080 by 720 picture area and adds dark space above and below for copy. Both bicycle wheels remain visible because the composition fits the image rather than filling the portrait frame by cropping it.
The source used here is the 1536 by 1024 featured illustration. For your own campaign, use an ordinary 8-bit RGB or RGBA PNG at least 1080 by 720 pixels. The example deliberately rejects small source downloads. A different aspect ratio can leave additional padding; inspect the result rather than assuming every illustration will suit this layout.
You can download the full-size source illustration and save it as artwork.png. Use that original file; the smaller image displayed by the page may be a responsive thumbnail.
A useful art brief specifies the subject, palette, composition and where software will put the words. The exact prompt used for this example is included below; another generation will not produce identical pixels.
Source illustration prompt
Use case: stylized-concept
Asset type: landscape source artwork and article featured image for a tutorial about assembling a silent business promo from AI artwork with FFmpeg.
Primary request: a polished editorial illustration for the clearly fictional bicycle business Juniper Cycle. Show a handsome forest-green city bicycle beside a quiet coastal path at sunrise, expressing a fresh start and enjoying a ride.
Scene: warm cream and pale apricot sky, a low golden sun, dark teal coastal grasses, simple distant water and softly shaped hills. The bicycle is the unmistakable focal subject near the center, fully visible with both wheels and plausible frame geometry.
Style: sophisticated contemporary screen-print illustration with subtle paper grain, bold considered shapes, restrained forest green, warm ivory and amber palette. Flat editorial art with depth from overlapping silhouettes, not a photorealistic product claim or a 3D desktop scene.
Composition: 1536 by 1024 landscape. Keep the entire bicycle and sun comfortably within the inner 80 percent of the image. This artwork will be fitted without cropping into a portrait video canvas; business copy will be added later by software in separate bands.
Constraints: no typography, letters, numbers, logos, watermarks, signs, people, promotional prices or fake screenshots. Do not draw interface elements or add campaign wording.
For a related approach with responsive website copy instead of an encoded video, see Build a Reusable Campaign Hero With AI Art and Editable HTML.
Write the campaign as a small editable brief
Save this as campaign.json. The newline escapes deliberately split copy into separate lines. The first card appears from 0 to just before 4 seconds, the second from 4 to just before 8, and the final invitation from 8 to the end. The brand, headline and fictional-campaign disclosure persist throughout, subject to the short overall fades.
{
"brand": "JUNIPER CYCLE",
"headline": "MAKE ROOM\nFOR THE RIDE",
"cards": [
"A little care.\nA fresh start.",
"Your next ride\nstarts here.",
"Plan your service.\nTalk to Juniper Cycle."
],
"disclosure": "FICTIONAL CAMPAIGN / AI ARTWORK"
}
These short phrases are sample creative choices, not tested marketing recommendations. Replace them with reviewed wording and a real destination when making an actual campaign. The demonstration contains no working booking link.
The script limits line count and character count to catch obvious overflows, but those checks do not measure glyph widths. A line of wide letters, a different font or another language still needs visual review. The fixed layout does not automatically wrap or shrink arbitrary copy.
Render a version you can reproduce
You need Python plus ffmpeg and ffprobe on PATH. The FFmpeg build must include drawtext and the libx264 encoder. The actual run used Windows 11, Python 3.14.3 and FFmpeg/ffprobe 8.1.1, the installed full Windows build from gyan.dev. Arial Bold came from the existing Windows font file; the font is not bundled with the example.
Save the renderer and verifier below beside the campaign file and source image. The commands use a Windows font path. On another system, supply a suitable font file you have available and recheck the layout. Cross-platform output was not tested.
Complete render_promo.py
"""Assemble a fixed 12-second silent promo; no model or network calls."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
def run(args, cwd=None):
r = subprocess.run(args, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if r.returncode:
raise RuntimeError(r.stderr[-2000:])
return r
def load_campaign(path):
data = json.loads(path.read_text(encoding="utf-8"))
if set(data) != {"brand", "headline", "cards", "disclosure"}:
raise ValueError("Use exactly brand, headline, cards and disclosure")
if not isinstance(data["cards"], list) or len(data["cards"]) != 3:
raise ValueError("Three four-second cards are required")
for name, value, max_lines, max_chars in [
("brand", data["brand"], 1, 24),
("headline", data["headline"], 2, 18),
("disclosure", data["disclosure"], 1, 42),
*[("card", card, 2, 28) for card in data["cards"]],
]:
if not isinstance(value, str) or not value.strip():
raise ValueError(name + " must be nonempty text")
lines = value.split("\n")
if len(lines) > max_lines or any(not s.strip() or len(s) > max_chars for s in lines):
raise ValueError(name + " exceeds the example's text limits")
if any(ord(c) < 32 and c != "\n" for c in value):
raise ValueError("Control characters are not supported")
return data
def render(image, campaign, font, out):
image, campaign, font, out = [Path(p).resolve() for p in (image, campaign, font, out)]
data = load_campaign(campaign)
if not image.is_file() or not font.is_file():
raise ValueError("Image and font must be existing files")
if out.exists():
raise ValueError("Output folder exists; choose a new versioned folder")
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
raise ValueError("Put ffmpeg and ffprobe on PATH")
# Fixed relative text filenames simplify filter parsing. Font paths with
# these characters are deliberately outside this small example's contract.
if any(c in font.as_posix() for c in "'[];,\n\r"):
raise ValueError("Use a font path without filter punctuation")
info = json.loads(run(["ffprobe", "-v", "error", "-show_streams", "-of", "json", str(image)]).stdout)
stream = info["streams"][0]
if stream.get("codec_name") != "png" or stream.get("pix_fmt") not in {"rgb24", "rgba"}:
raise ValueError("Use an ordinary 8-bit RGB or RGBA PNG source")
if stream["width"] < 1080 or stream["height"] < 720:
raise ValueError("Use at least a 1080 by 720 source; this fixture rejects small downloads")
out.mkdir(parents=True)
text = {"brand": data["brand"], "headline": data["headline"],
"disclosure": data["disclosure"],
**{f"card{i}": value for i, value in enumerate(data["cards"])}}
for name, value in text.items():
for line_number, line in enumerate(value.split("\n")):
(out / f"{name}-{line_number}.txt").write_text(line, encoding="utf-8")
font_arg = "'" + font.as_posix().replace(":", "\\:") + "'"
def overlay(name, size, y, color="0xfff3d6", enable=None):
lines = []
# Separate filters give each line an explicit position across fonts.
for line_number, _ in enumerate(text[name].split("\n")):
top = y + line_number * (size + 24)
value = (f"drawtext=fontfile={font_arg}:textfile={name}-{line_number}.txt:expansion=none:"
f"fontsize={size}:fontcolor={color}:x=(w-text_w)/2:y={top}")
lines.append(value + (f":enable='{enable}'" if enable else ""))
return lines
filters = [
"scale=1080:720:force_original_aspect_ratio=decrease:force_divisible_by=2",
"pad=1080:720:(ow-iw)/2:(oh-ih)/2:color=0x0b2426",
"setsar=1", "pad=1080:1920:0:520:color=0x0b2426",
*overlay("brand", 48, 150, "0xffc570"),
*overlay("headline", 82, 275),
*overlay("disclosure", 25, 1720, "0xbdcbc5"),
]
for i in range(3):
filters.extend(overlay(f"card{i}", 56, 1370,
enable=f"gte(t,{i*4})*lt(t,{(i+1)*4})"))
filters += ["fade=t=in:st=0:d=0.3", "fade=t=out:st=11.7:d=0.3", "format=yuv420p"]
graph = ",".join(filters)
(out / "filtergraph.txt").write_text(graph, encoding="utf-8")
cmd = ["ffmpeg", "-hide_banner", "-nostdin", "-n", "-loop", "1",
"-framerate", "30", "-i", str(image), "-vf", graph,
"-frames:v", "360", "-an", "-c:v", "libx264", "-preset", "medium",
"-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "promo.mp4"]
result = run(cmd, cwd=out)
(out / "render-log.txt").write_text(result.stderr, encoding="utf-8")
manifest = {"source_sha256": hashlib.sha256(image.read_bytes()).hexdigest(),
"campaign": data, "font_name": font.name,
"font_sha256": hashlib.sha256(font.read_bytes()).hexdigest(),
"ffmpeg": run(["ffmpeg", "-version"]).stdout.splitlines()[0],
"command": cmd, "output": "promo.mp4", "expected_frames": 360,
"expected_seconds": 12, "audio": False}
(out / "render-manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return out / "promo.mp4"
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--image", required=True)
parser.add_argument("--campaign", default="campaign.json")
parser.add_argument("--font", required=True)
parser.add_argument("--out", default="render-v1")
args = parser.parse_args()
print(render(args.image, args.campaign, args.font, args.out))
Complete verify_promo.py
"""Check the encoded artifact and extract representative frames for review."""
import argparse
import hashlib
import json
from pathlib import Path
import struct
import subprocess
def run(args):
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode:
raise RuntimeError(r.stderr[-2000:])
return r
def verify(path):
path = Path(path).resolve()
info = json.loads(run(["ffprobe", "-v", "error", "-count_frames", "-show_streams",
"-show_format", "-of", "json", str(path)]).stdout)
videos = [s for s in info["streams"] if s["codec_type"] == "video"]
audio = [s for s in info["streams"] if s["codec_type"] == "audio"]
if len(videos) != 1:
raise ValueError("Expected one video stream")
v = videos[0]
expected = {"codec_name": "h264", "width": 1080, "height": 1920,
"pix_fmt": "yuv420p", "avg_frame_rate": "30/1", "nb_read_frames": "360",
"sample_aspect_ratio": "1:1"}
for key, value in expected.items():
if v.get(key) != value:
raise ValueError(f"Unexpected {key}: {v.get(key)}")
if audio or abs(float(info["format"]["duration"]) - 12) > 0.001:
raise ValueError("Expected a silent twelve-second clip")
# Walk MP4 top-level boxes; do not mistake text in media data for a box.
boxes = []
with path.open("rb") as f:
while f.tell() < path.stat().st_size:
start = f.tell()
size, kind = struct.unpack(">I4s", f.read(8))
if size == 1:
size = struct.unpack(">Q", f.read(8))[0]
elif size == 0:
size = path.stat().st_size - start
if size < f.tell() - start or start + size > path.stat().st_size:
raise ValueError("Invalid MP4 box")
boxes.append(kind.decode("ascii"))
f.seek(start + size)
if boxes.index("moov") > boxes.index("mdat"):
raise ValueError("Faststart metadata is not ahead of media")
run(["ffmpeg", "-hide_banner", "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"])
for second in (2, 6, 10):
frame = path.parent / f"frame-{second:02d}.png"
run(["ffmpeg", "-hide_banner", "-v", "error", "-nostdin", "-n", "-ss", str(second),
"-i", str(path), "-frames:v", "1", "-update", "1", str(frame)])
result = {"status": "passed", **expected, "duration_seconds": 12,
"audio_streams": len(audio), "full_decode_passed": True,
"top_level_boxes": boxes, "moov_before_mdat": True,
"video_bytes": path.stat().st_size,
"video_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"frames_for_visual_review": ["frame-02.png", "frame-06.png", "frame-10.png"],
"limits": "Structural and decode checks do not establish text legibility, campaign effectiveness or platform acceptance."}
(path.parent / "probe.json").write_text(json.dumps(info, indent=2), encoding="utf-8")
(path.parent / "verification.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("video")
print(json.dumps(verify(parser.parse_args().video), indent=2))
python -X utf8 render_promo.py --image artwork.png --campaign campaign.json --font C:/Windows/Fonts/arialbd.ttf --out render-v1
python -X utf8 verify_promo.py render-v1/promo.mp4
The renderer refuses an existing output folder. For a revision, use a new folder such as render-v2; preserve the prior campaign and output for comparison. If a run fails after creating its folder, inspect that folder and use a new version for the corrected run. The verifier also refuses to overwrite its three extracted review frames, so keep each verification with its render.
Each output folder contains the MP4, individual UTF-8 text files, the generated filter graph, the encoding log and a manifest with source/font hashes and the command. These records identify the inputs and settings. They do not promise byte-identical encoding across different FFmpeg builds or fonts.
What the rendering decisions do
The renderer fits and pads the source, then overlays text from files with expansion=none. Each line has its own position so font-dependent multiline spacing cannot push the second line into the picture. Timeline expressions enable each card for its assigned interval. These controls are documented in the FFmpeg filter reference.
Using text files also keeps campaign punctuation out of filter syntax. The Python process passes an argument list to FFmpeg without invoking a shell. The example deliberately restricts punctuation in font paths; use a straightforward font path if it rejects yours.
The output has 360 frames at 30 frames per second. It uses H.264 with yuv420p pixel format and square pixels. The -an option makes the clip intentionally silent; it does not contain a silent audio stream, speech or music. The only changes over time are the copy cards and two 0.3-second fades.
-movflags +faststart places the MP4’s index ahead of its media data, as described in the FFmpeg format documentation. That is a file-layout choice, not proof that a social network or ad system will accept the file.
Inspect the encoded file, not just the command’s exit code
The verifier uses ffprobe’s JSON metadata and frame counting, decodes the full video with FFmpeg and inspects the actual top-level MP4 boxes. It also extracts frames at 2, 6 and 10 seconds for visual review.
| Check | Observed final result |
|---|---|
| Frame size and pixel shape | 1080 × 1920, square pixels |
| Video encoding | H.264, yuv420p |
| Timing | 360 decoded frames, 30 fps, 12 seconds |
| Audio streams | 0, intentionally silent |
| MP4 metadata | moov appears before mdat |
| Full decode | Completed without reported errors |
| Final file size | 874,285 bytes for this source and build |
| Three representative frames | Correct card, clear headline, complete bicycle and visible disclosure |
Two negative checks also passed: the renderer refused an existing output folder and rejected an overlong headline. These are modest guardrails, not a complete media-validation suite.
Visual inspection mattered. An initial render encoded successfully but its automatic multiline spacing pushed part of the headline over the artwork. The corrected renderer gives every line an explicit position. The final frames were inspected again and the overlap was gone. A valid MP4 can still contain a poor layout.
Adapt the template without hiding its limits
Use the JSON file for copy revisions, the source image for art revisions, and the renderer for layout changes. A new message should not require regenerating an otherwise suitable illustration. A new aspect ratio does require a layout decision; changing the canvas dimensions alone will not reposition the text safely.
Preview the clip at its intended display size and in the destination’s actual upload interface before releasing it. Platform buttons, captions and crops may cover areas that look clear in the local file. The margins used here are design choices, not verified platform safe zones. Test image colors and text readability after any destination re-encoding.
The words are burned into the video and are no longer editable inside that MP4. Keep the JSON and source files for revisions. The clip has no spoken content, and its on-screen copy is available in the campaign block above; when you publish a real version, provide an equivalent text description for people who cannot use the visual message. Formal accessibility and platform acceptance were not tested here.
For a business owner or AI engineer, the reusable part is the production boundary: use an image model for the illustration, then use a repeatable renderer for exact wording and timing. That produces an inspectable campaign asset without tying every text correction to another generation.
Jason Ead publishes AlgoThoughts for people building useful business technology. My Biz Heroes focuses on websites, automation, workflows, AI tools and training. Read more about Jason and the publication.
