레시피
PopNow AI 이미지, 영상, 음성 변환 API를 위한 복사해서 바로 실행하는 Python과 Node.js 레시피. CSV로 이미지 일괄 생성, 사진→영상, 줄 단위 내레이션, 영상이 준비되면 Slack으로 알림을 보내는 예제를 담았습니다.
사람들이 가장 먼저 자동화하는 작업 네 가지를 짧은 스크립트로 담았습니다. 각 스크립트는 완전한 형태입니다. requests를 설치하거나(Node 18+ 사용), POPNOW_API_KEY를 설정하고, 실행하면 됩니다. 네 스크립트는 같은 헬퍼 세 개(호출, 대기, 저장)를 공유하므로, 하나를 읽고 나면 나머지는 몇 줄씩만 추가로 보면 됩니다. popnow.ai의 레시피 페이지에서는 고른 모델에 맞는 같은 스크립트를 만들어 줍니다.
| 레시피 | 모델 | 항목당 비용 |
|---|---|---|
| CSV로 이미지 일괄 생성 | alibaba/z-image-turbo/text-to-image | 이미지당 $0.01 |
| 사진→영상 | minimax/h3/image-to-video | 768p에서 5초에 $0.185 |
| 줄마다 내레이션 | 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/ 안의 이미지 한 장이 됩니다. 모든 작업을 먼저 보낸 뒤 나중에 결과를 모으므로, 작업들이 병렬로 생성됩니다. 멱등성 키는 행에서 만들어지므로, 중간에 충돌한 뒤 다시 실행해도 이미 처리된 행에 두 번 과금되지 않습니다.
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}}'줄마다 내레이션
lines.txt에는 대본 한 줄마다 한 행씩 들어 있습니다. 각 줄은 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 수신 웹훅에 보냅니다. 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도 함께 보내세요. 재시도와 중복 전송은 웹훅에서 다룹니다.
가격과 한도
각 레시피는 항목마다 모델의 일반 가격을 별도의 API USD 잔액에서 결제하며 그 밖의 요금은 없습니다. 작업 조회와 파일 업로드는 무료입니다. 실패한 항목은 비용이 없고, 항목마다 자체 멱등성 키가 있으므로 스크립트를 다시 실행해도 아직 접수되지 않은 항목만 만들어집니다. 대량 실행 전에는 jobs/quote로 한 항목의 가격을 확인하고 키에 일일 한도를 설정하세요(인증). 요청 제한 안에서는 수백 개의 작업을 한꺼번에 보내도 됩니다.
자주 나는 오류
| 오류 | 원인 | 해결 |
|---|---|---|
KeyError: 'POPNOW_API_KEY' | 이 셸에 변수가 설정되지 않음 | export POPNOW_API_KEY="sk-popnow-..." |
필드 이름이 나오는 RuntimeError: 400 … | 모델이 그 필드나 값을 받지 않음 | 모델 페이지를 확인하세요. 필드는 모델마다 다릅니다 |
RuntimeError: 40201 … | 다음 작업에 필요한 API USD가 부족함 | API billing에서 입금하세요. 이미 접수된 작업은 계속 실행됩니다 |
RuntimeError: 429 … | 동시에 요청이 너무 많음 | 더 작은 묶음으로 보내고 Retry-After를 지키세요 |
RuntimeError: failed: … | 항목 하나가 실패했거나 거절됨 | 그 항목에는 비용이 없습니다. 해당 행을 고쳐 다시 실행하세요 |
commit에서 RuntimeError: 40003 … | 업로드한 바이트가 티켓과 맞지 않음 | 새 티켓을 받아 다시 업로드하세요 |
웹훅이 401을 돌려줌 | 원본 바이트가 아니라 파싱된 JSON으로 서명을 계산했거나, 시크릿이 틀림 | express.raw를 쓰고, API 콘솔의 시크릿을 사용하세요 |
공식 Python 또는 Node SDK가 있나요?
네. 둘 다 GitHub에서 오픈소스로 공개되어 있고, PyPI와 npm에 popnow로 올라가 있습니다. pip install popnow와 npm install popnow로 설치하세요. 자세한 내용은 SDK, CLI, 오픈소스 도구에 있습니다. 이 레시피들은 아무것도 더 설치하지 않도록 requests와 내장 fetch만 씁니다.
배치 하나에서 동시에 몇 개의 작업을 돌릴 수 있나요?
많이 돌릴 수 있습니다. 모두 보낸 뒤 결과를 모으세요. 요청 제한 페이지의 한도 안에서 쓰고, 배치에 쓰는 키에는 일일 지출 한도를 거세요.
배치 도중에 스크립트가 중단되면 어떻게 되나요?
다시 실행하세요. 각 행은 이전과 같은 멱등성 키를 쓰므로, 이미 접수된 행은 두 번 과금되지 않고 원래 작업을 돌려받습니다.

