レシピ
PopNow AI の画像・動画・音声合成 API を、コピーして実行できる Python と Node.js のレシピで。CSV からの画像の一括生成、写真から動画、1 行ずつのナレーション、動画の完成時の Slack 通知。
最初に自動化されることの多い作業を、4 つの小さなスクリプトにまとめました。どれも完結しています。requests を入れる(または Node 18 以降を使う)、POPNOW_API_KEY を設定する、実行する、の 3 つで動きます。どのスクリプトも同じ 3 つのヘルパー(呼び出し、待機、保存)を使うので、1 つ読めば、ほかは数行ずつの違いです。popnow.ai のレシピページでは、選んだ任意のモデル向けに同じスクリプトを作れます。
| レシピ | モデル | 1 件あたりの料金 |
|---|---|---|
| CSV からの画像の一括生成 | alibaba/z-image-turbo/text-to-image | $0.01/画像 1 枚 |
| 写真から動画 | minimax/h3/image-to-video | 768p・5 秒で $0.185 |
| 1 行ずつのナレーション | google/gemini-3.8-flash-tts/text-to-speech | 20 秒の音声あたり約 $0.007 |
| 動画の完成時の Slack 通知 | minimax/h3/image-to-video | 768p・5 秒で $0.185 |
モデルにある別のモデルに差し替えられます。input のフィールドは、そのモデルのスキーマに載っているものに合わせてください。
ヘルパー
すべての Python のレシピは、末尾でこれらを使います。create は冪等キー付きでタスクを送ります。wait は、タスクが最終状態になりファイルのコピーも終わるまで、間隔を広げながらポーリングします。save は最初のファイルをダウンロードします。
import os, time
import requests
BASE = "https://api.popnow.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['POPNOW_API_KEY']}"}
def call(method, path, **kwargs):
headers = {**HEADERS, **kwargs.pop("headers", {})}
body = requests.request(method, BASE + path, headers=headers, timeout=60, **kwargs).json()
if body["code"] != 200:
raise RuntimeError(f'{body["code"]} {body["msg"]}')
return body["data"]
def create(payload, key):
return call("POST", "/jobs/createTask", json=payload, headers={"Idempotency-Key": key})["taskId"]
def wait(task_id):
delay = 2.0
while True:
time.sleep(delay)
task = call("GET", "/jobs/recordInfo", params={"taskId": task_id})
assets = (task.get("output") or {}).get("assets") or []
copying = any(a.get("pending") and not a.get("unavailable") for a in assets)
if task["state"] in ("succeeded", "failed", "canceled", "expired") and not copying:
return task
delay = min(delay * 1.5, 15.0)
def save(task, path):
if task["state"] != "succeeded":
raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "no detail")}')
url = task["output"]["assets"][0]["url"]
with open(path, "wb") as f:
f.write(requests.get(url, timeout=300).content)CSV からの画像の一括生成
prompts.csv には prompt 列があり、各行が out/ の画像 1 枚になります。すべてのタスクを先に送り、あとでまとめて回収するので、並列にレンダリングされます。冪等キーは行から導出されるため、途中でクラッシュして再実行しても、すでに処理された行に二重に課金されることはありません。
import csv, hashlib, pathlib
from helpers import create, wait, save
MODEL = "alibaba/z-image-turbo/text-to-image"
out = pathlib.Path("out")
out.mkdir(exist_ok=True)
with open("prompts.csv", newline="", encoding="utf-8") as f:
rows = [r["prompt"] for r in csv.DictReader(f) if r.get("prompt")]
tasks = []
for i, prompt in enumerate(rows, start=1):
key = "batch-" + hashlib.sha256(f"{i}:{prompt}".encode()).hexdigest()[:32]
tasks.append((i, create({"model": MODEL, "input": {"prompt": prompt, "aspect_ratio": "1:1"}}, key)))
for i, task_id in tasks:
save(wait(task_id), out / f"{i:03d}.png")
print("saved", i)同じ内容を、パッケージなしの Node.js 18 以降で書くと次のとおりです。
import { createHash } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
const API = 'https://api.popnow.ai/api/v1';
const auth = { Authorization: `Bearer ${process.env.POPNOW_API_KEY}` };
const call = async (path, init = {}) => {
const body = await fetch(API + path, { ...init, headers: { ...auth, ...init.headers } }).then((r) => r.json());
if (body.code !== 200) throw new Error(`${body.code} ${body.msg}`);
return body.data;
};
const prompts = (await readFile('prompts.txt', 'utf8')).split('\n').map((l) => l.trim()).filter(Boolean);
await mkdir('out', { recursive: true });
const ids = await Promise.all(prompts.map((prompt, i) => call('/jobs/createTask', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'batch-' + createHash('sha256').update(`${i}:${prompt}`).digest('hex').slice(0, 32) },
body: JSON.stringify({ model: 'alibaba/z-image-turbo/text-to-image', input: { prompt, aspect_ratio: '1:1' } }),
}).then((d) => d.taskId)));
for (const [i, id] of ids.entries()) {
let task;
do {
await new Promise((r) => setTimeout(r, 3000));
task = await call(`/jobs/recordInfo?taskId=${id}`);
} while (task.state === 'queued' || task.state === 'running');
if (task.state !== 'succeeded') { console.error(i, task.state, task.errorMessage); continue; }
const file = await fetch(task.output.assets[0].url).then((r) => r.arrayBuffer());
await writeFile(`out/${String(i + 1).padStart(3, '0')}.png`, Buffer.from(file));
}写真から動画
ローカルの写真をアップロードしてから、画像から動画へのモデルで動かします。python photo_to_video.py photo.jpg と実行してください。
import mimetypes, sys, uuid
import requests
from helpers import call, create, wait, save
MODEL = "minimax/h3/image-to-video"
def upload(path):
"""Local file -> popnow:// reference you can put in any image, video or audio field."""
data = open(path, "rb").read()
kind = mimetypes.guess_type(path)[0] or "image/jpeg"
ticket = call("POST", "/common/upload-url", json={"contentType": kind, "bytes": len(data)})
requests.put(ticket["uploadUrl"], data=data, headers=ticket.get("headers", {}), timeout=120).raise_for_status()
return call("POST", f'/files/{ticket["fileId"]}/commit')["uri"]
ref = upload(sys.argv[1])
task_input = {"image_url": ref, "prompt": "Gentle camera push-in, hair moving in the wind", "duration_seconds": 5, "resolution": "768p"}
task_id = create({"model": MODEL, "input": task_input}, str(uuid.uuid4()))
save(wait(task_id), "clip.mp4")
print("saved clip.mp4")コードを書く前に、リクエストだけを試す場合は次のとおりです。
curl -X POST "https://api.popnow.ai/api/v1/jobs/createTask" \
-H "Authorization: Bearer $POPNOW_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"model": "minimax/h3/image-to-video", "input": {"image_url": "https://media.popnow.ai/movie/lighthouse.webp", "prompt": "The waves roll in slowly", "duration_seconds": 5}}'1 行ずつのナレーション
lines.txt には台本の 1 行を 1 つずつ書きます。各行は voice/ 内で独自の音声ファイルになり、すべて同じ声になります。voice_id の候補には Kore、Puck、Charon、Aoede があります。モデルのページには、style と accent とあわせてすべてが載っています。
import hashlib, pathlib
from helpers import create, wait, save
MODEL = "google/gemini-3.8-flash-tts/text-to-speech"
out = pathlib.Path("voice")
out.mkdir(exist_ok=True)
lines = [l.strip() for l in open("lines.txt", encoding="utf-8") if l.strip()]
tasks = []
for i, line in enumerate(lines, start=1):
key = "voice-" + hashlib.sha256(f"{i}:{line}".encode()).hexdigest()[:32]
tasks.append((i, create({"model": MODEL, "input": {"text": line, "voice_id": "Kore", "style": "empathetic"}}, key)))
for i, task_id in tasks:
task = wait(task_id)
mime = ((task.get("output") or {}).get("assets") or [{}])[0].get("mime", "")
ext = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/ogg": ".ogg"}.get(mime, ".audio")
save(task, out / f"{i:03d}{ext}")
print("saved", i)動画の完成時の Slack 通知
ポーリングする代わりに、タスクを作成するときに callBackUrl を渡します。小さな Express サーバーが生のボディで署名を確認し、結果を Slack の受信 Webhook に投稿します。POPNOW_WEBHOOK_SECRET(API コンソールから取得)と SLACK_WEBHOOK_URL を設定してください。
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import express from 'express';
const BASE = 'https://api.popnow.ai/api/v1';
// 1. Create the task with a callBackUrl that points at this server.
export async function createVideo() {
const res = await fetch(BASE + '/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POPNOW_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
model: 'minimax/h3/image-to-video',
input: { image_url: 'https://media.popnow.ai/movie/lighthouse.webp', prompt: 'The waves roll in slowly', duration_seconds: 5 },
callBackUrl: 'https://your-server.example.com/hooks/popnow',
}),
});
return (await res.json()).data.taskId;
}
// 2. Receive the finished task, check the signature on the raw body, then post to Slack.
const app = express();
app.post('/hooks/popnow', express.raw({ type: 'application/json' }), async (req, res) => {
const timestamp = req.header('X-Webhook-Timestamp');
const signature = req.header('X-Webhook-Signature') ?? '';
const event = JSON.parse(req.body.toString('utf8'));
const task = event.data;
const digest = createHash('sha256').update(req.body).digest('hex');
const expected = createHmac('sha256', process.env.POPNOW_WEBHOOK_SECRET)
.update(`${task.taskId}.${timestamp}.${digest}`)
.digest('base64');
const ok = expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!ok || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.status(401).end();
res.status(200).end(); // answer first; deliveries are retried until you return 2xx
const text = task.state === 'succeeded'
? `Video ready: ${task.output.assets[0].url}`
: `Task ${task.taskId} ${task.state}: ${task.errorMessage ?? 'no detail'}`;
await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
});
app.listen(3000);Slack のメッセージにあるリンクは署名付きで、約 20 分間有効です。あとから開く可能性がある場合は、taskId も一緒に投稿してください。再試行と重複配信については Webhookを参照してください。
料金と上限
各レシピは、1 件ごとにそのモデルの通常料金を独立した API USD 残高から支払い、それ以外の料金はありません。タスクの読み取りとファイルのアップロードは無料です。失敗した件は課金されず、各件が独自の冪等キーを持つので、スクリプトを再実行しても、まだ受け付けられていない件だけが作られます。大量に実行する前に、jobs/quote で 1 件の料金を確認し、キーに 1 日の利用上限を設定してください(認証)。数百のタスクを一度に送ること自体は、レート制限の範囲内であれば問題ありません。
よくあるエラー
| エラー | 原因 | 対処 |
|---|---|---|
KeyError: 'POPNOW_API_KEY' | このシェルで変数が設定されていない | export POPNOW_API_KEY="sk-popnow-..." |
フィールド名を含む RuntimeError: 400 … | モデルがそのフィールドや値を受け付けない | モデルのページを確認してください。フィールドはモデルごとに異なります |
RuntimeError: 40201 … | 次のタスクに必要な API USD が足りない | API billing で入金してください。受け付け済みのタスクは実行を続けます |
RuntimeError: 429 … | 一度に送るリクエストが多すぎる | 小さなグループに分けて送り、Retry-After に従ってください |
RuntimeError: failed: … | 1 件が失敗、または断られた | その件には課金されていません。その行を直して再実行してください |
コミット時の RuntimeError: 40003 … | アップロードしたバイト列がチケットと一致しない | 新しいチケットを取得して、もう一度アップロードしてください |
Webhook が 401 を返す | 生のバイト列ではなく解析後の JSON で署名を計算している、または秘密鍵が違う | express.raw を使い、API コンソールの秘密鍵を使ってください |
公式の Python や Node の SDK はありますか?
はい。どちらも GitHub でオープンソースとして公開されており、PyPI と npm では popnow として公開されています。pip install popnow と npm install popnow です。詳細は SDK、CLI、オープンソースツールにあります。このレシピは、余分なものを入れなくても動くように、requests と組み込みの fetch だけを使っています。
一括処理で、いくつのタスクを同時に実行できますか?
多くのタスクを実行できます。すべてを送ってから回収してください。レート制限のページにある上限の範囲内に収め、一括処理に使うキーには 1 日の利用上限を設定してください。
一括処理の途中でスクリプトがクラッシュしたら?
もう一度実行してください。各行は以前と同じ冪等キーを使うので、すでに受け付けられた行は、二重に課金されず、元のタスクを返します。

