Turn a Photo and a Script Into a Talking Video in Python (Standard Library Only)
I work on TalkPix. Most "make a talking video from a photo" integrations I see start with a third-party SDK, a task queue and a webhook server before the first video ever renders. You don't need any of that to get goi
I work on TalkPix.
Most "make a talking video from a photo" integrations I see start with a third-party SDK, a task queue and a webhook server before the first video ever renders. You don't need any of that to get going. A talking-photo render is a long-running job: you start it, you wait about a minute, you fetch an MP4. That shape fits in one Python file with nothing but the standard library.
This post walks through that file for the TalkPix Developer API: estimate the cost, start the render, poll until it finishes, and save the result. Then it covers the mistakes that tend to cost money or videos.
The problem
The API takes a portrait (image_url), a script, a voice and a language, and returns a lip-synced MP4. It is asynchronous by design:
-
POST /api/v1/videosreturns201immediately with anidandstatus: "processing". -
GET /api/v1/videos/:idmoves throughqueued → processing → completed | failed. - When it is
completed,video_urlis set.
Billing is per rendered second (1 credit/s at 720p, 2 at 1080p, minimum 3 seconds). Credits are reserved when the render starts and the final charge follows the real length. So the code has to do three things well: know the cost before it starts, wait patiently, and handle the failure cases without starting a second render by accident.
The code
Set TALKPIX_API_KEY in your environment (keys start with tpx_live_, created on Account → API). Run this on a server or your own machine, never in a browser or mobile app.
import json
import os
import time
import urllib.error
import urllib.request
BASE = "https://www.talkpix.ai/api/v1"
KEY = os.environ["TALKPIX_API_KEY"] # tpx_live_…
class TalkPixError(Exception):
def __init__(self, status, body):
err = (body or {}).get("error", {})
self.status = status
self.type = err.get("type")
self.param = err.get("param")
super().__init__(f"{status} {self.type}: {err.get('message')}")
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
BASE + path,
data=data,
method=method,
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
try:
payload = json.loads(e.read())
except ValueError:
payload = None
raise TalkPixError(e.code, payload) from None
def estimate(script, resolution="720p"):
return call("POST", "/estimate", {"script": script, "resolution": resolution})
def start(image_url, script, voice="zephyr", language="en-US", order_id=None):
body = {
"image_url": image_url,
"script": script,
"voice": voice,
"language": language,
}
if order_id:
body["metadata"] = {"order_id": order_id}
return call("POST", "/videos", body)
def wait(video_id, interval=5, timeout=900):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
video = call("GET", f"/videos/{video_id}")
except TalkPixError as e:
if e.status == 429 or e.status >= 500:
time.sleep(interval * 2)
continue
raise
except (urllib.error.URLError, TimeoutError):
time.sleep(interval * 2) # network blip: keep polling, don't re-create
continue
if video["status"] == "completed":
return video
if video["status"] == "failed":
raise RuntimeError(f"render failed: {video.get('error')}")
time.sleep(interval)
raise TimeoutError(f"{video_id} still running after {timeout}s")
def download(url, path):
with urllib.request.urlopen(url, timeout=120) as resp, open(path, "wb") as f:
while chunk := resp.read(1 << 16):
f.write(chunk)
if __name__ == "__main__":
script = "Hi! This video was made with one API call."
print("estimate:", estimate(script))
video = start("https://example.com/portrait.jpg", script, order_id="A-1042")
print("started", video["id"], "reserved", video["credits_reserved"])
done = wait(video["id"])
print("charged", done["credits_charged"], "for", done["duration_seconds"], "s")
download(done["video_url"], f"{done['id']}.mp4")
That is the whole integration: about 90 lines, no pip install. If you'd rather not maintain it, the official Python client (pip install talkpix) has the same methods, also uses only the standard library, and includes a wait_for_completion helper. Source and more examples are on GitHub.
The pitfalls
1. Never blindly retry the POST. If POST /videos times out on your side, the render may already have started and reserved credits. Retrying the create call can give you two videos and two charges. Retry the GET as often as you like; for the POST, send your own order_id in metadata and, after a timeout, check GET /videos (newest first) for a render carrying that id before you create another one.
2. Branch on error.type, not the message. The error envelope has a stable type. The useful ones here:
-
insufficient_credits(402): nothing was started. Stop and top up; retrying won't help. -
invalid_request_error(400):paramnames the field. Also returned when the text fails the content check, so log it and don't loop. -
rate_limit_error(429): over 120 requests a minute on one key, or 20 renders already in progress. Back off. -
provider_error(500): the render could not start upstream, and the credits were already returned.
3. Poll at a sane interval. A typical 10-second video takes about a minute, and the docs say every 5 seconds is fine. GET /videos/:id also reconciles with the renderer on each call, so the status keeps moving even if an internal webhook is late. Polling every 200 ms only burns your rate limit.
4. Save the MP4 yourself. video_url is hosted for 30 days, then removed. If your app shows the video later, copy it into your own storage as soon as it completes, which is why download() runs right after wait().
5. Estimate first, and expect a small difference. POST /estimate reserves nothing and costs no credits. The final credits_charged follows the real rendered length, rounded up to the next second, and the difference is refunded or charged when the job finishes. As a rule of thumb, about 14 characters of script is one second of video. 720p allows up to 300 seconds; 1080p is capped at 120 seconds and costs twice as much per second.
6. A failed render is refunded. When status is failed, credits_charged is 0 and credits_refunded turns true. Your code doesn't need a refund path of its own, but it should surface error.code (safety means the content filter rejected the photo or text).
Where to go next
- When you outgrow polling, pass
webhook_urland verify theTalkPix-Signatureheader (HMAC-SHA256 oft.rawBody). Keep polling as the fallback, since events are at-least-once. - If the photo isn't on a public URL,
POST /uploadsgives you a 5-minute PUT URL, and you pass the returnedimage_keyinstead ofimage_url. - If you'd rather make videos from a chat than from code, the same account works in Claude through the TalkPix MCP server.
Credit packs and per-second rates are on the pricing page. Credits never expire and there is no subscription.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.