Muse Image has no seed, so the same prompt never draws the same person twice. That makes a recurring AI character, the kind an influencer account needs, the hard part. This tutorial builds one in about a hundred lines of Node.js: generate a character sheet with Muse Image, cut it into a headshot and a turnaround, and hand both to Muse Video as reference images to get a 10-second vertical clip of the same woman.
Muse Image is Meta's image model (text-to-image, or one to four reference images, about 2.5 megapixels per image). Muse Video is Meta's video model (720p, 4 to 30 seconds, landscape or portrait, with an optional first frame or up to four reference images). The script calls both through one HTTP API.
Disclosure: the endpoints and prices below are from apimodels.app, the API I work on. apimodels.app is a multi-model API gateway: one key and one OpenAI-compatible endpoint for 110+ image, video, audio and language models; the two Muse models are muse-image and muse-video there. This post is AI-assisted: drafted with an AI model from our own docs and test notes, then checked by hand.
What you need
- Node.js 18 or newer (the script uses the built-in
fetchand top-levelawait). -
sharpfor cropping:npm install sharp. - An API key in
APIMODELS_API_KEY. - About $0.39 per run: $0.02 for the sheet and $0.037 per requested second for the clip (10 seconds = $0.37), at list prices on 1 November 2026. Failed tasks are not charged.
Step 1: one character sheet, not one portrait
Instead of asking for a nice photo, ask for a sheet: a large headshot in the left third and a front, side and back turnaround of the same woman on the right, on a plain light-grey background. One image, one person, every angle the video model needs.
Three things in the prompt matter more than the rest:
- A concrete adult age. "A 25-year-old woman." In our tests, words like "18", "student" or "freshman" made Muse draw someone visibly younger, so every character we make is 25.
- The outfit with its limits spelled out. Our date outfits show shoulders, arms and legs, and the prompt also says "modest neckline, no cleavage".
- "Same face, same hair and same outfit in every view." The sheet is only useful if the views agree.
Muse Image takes aspect_ratio (2:1 here) and renders about 2.5 megapixels, so a 2:1 sheet comes back around 2240 x 1120. One request returns one image; n above 1 is rejected.
Step 2: cut the sheet in two
We pass the headshot and the turnaround as two separate reference images, each described in the video prompt. The cut is not at a fixed fraction: on our sheets the headshot ended anywhere between 39% and 44% of the width, and on one of them it touched the turnaround with no gap at all.
splitSheet() in the script finds the first background gap to the right of the headshot. When there is no gap, it cuts where the two overlap least and prints a warning. We checked it against four of our own sheets: it landed inside the gap on the three that had one, and within a few pixels of our hand-made cut on the one that did not. It is still a heuristic, so open ref-head.jpg and ref-turnaround.jpg before you pay for a video.
Step 3: a time-coded video prompt with both references
The video prompt starts by saying what each image is for (image 1 is the face, image 2 is the outfit and body), repeats the age, and then reads like a shot list: scene, camera, one beat per second and a half, how she behaves, the sound, the style, and an Avoid line. References go in images as public URLs or base64 data URIs (PNG, JPEG or WebP, up to 20 MB each). The script sends data URIs, so nothing needs hosting.
duration: 10 with aspect_ratio: "9:16" returns a 720 x 1280 MP4. Clips up to 10 seconds usually finish in one to two minutes; 15 to 30 seconds take three to seven.
The whole script
// muse-influencer.mjs — Node 18+. Install: npm install sharp
// Run: APIMODELS_API_KEY=your_key node muse-influencer.mjs
import sharp from "sharp";
import { writeFile } from "node:fs/promises";
const API = "https://api.apimodels.app";
const H = { Authorization: `Bearer ${process.env.APIMODELS_API_KEY}`, "Content-Type": "application/json" };
// Create a task and return its id. 4xx responses carry the reason in msg.
async function create(path, body) {
const r = await fetch(API + path, { method: "POST", headers: H, body: JSON.stringify(body) });
const j = await r.json();
if (!r.ok || j.code !== 200) throw new Error(`${r.status}: ${j.msg}`);
return j.data.taskId;
}
// Poll until the task finishes. Failed tasks are not charged.
async function wait(path, taskId, everyMs = 5000, limitMs = 10 * 60_000) {
for (const until = Date.now() + limitMs; Date.now() < until; ) {
const { data } = await (await fetch(`${API}${path}?task_id=${taskId}`, { headers: H })).json();
if (!data) throw new Error(`unknown task ${taskId}`);
if (data.state === "completed") return data;
if (data.state === "failed") throw new Error(`${data.failMsg ?? "failed"} (not charged)`);
await new Promise((r) => setTimeout(r, everyMs));
}
throw new Error(`still running after ${limitMs / 1000}s: ${taskId}`);
}
// Split a Muse character sheet into the headshot (left) and the turnaround (right).
// The cut goes into the first background gap right of the headshot; if the headshot
// touches the turnaround, it goes where they overlap least. Always look at both crops.
async function splitSheet(buf) {
const { data, info } = await sharp(buf).greyscale().raw().toBuffer({ resolveWithObject: true })
const { width: w, height: h } = info
// Background grey = median of a thin strip along the top of the right half.
const strip = []
for (let y = 0; y < Math.max(4, Math.round(h * 0.03)); y++)
for (let x = Math.round(w / 2); x < w; x++) strip.push(data[y * w + x])
strip.sort((a, b) => a - b)
const bg = strip[strip.length >> 1]
// Share of a column's pixels that are clearly not background.
const busy = (x) => {
let n = 0
for (let y = 0; y < h; y++) if (Math.abs(data[y * w + x] - bg) > 25) n++
return n / h
}
const from = Math.round(w * 0.3)
const to = Math.round(w * 0.55)
let split = null
for (let x = from, start = -1; x <= to; x++) {
if (busy(x) < 0.05) { if (start < 0) start = x }
else if (start >= 0) {
if (x - start >= Math.max(8, Math.round(w * 0.005))) { split = Math.round((start + x) / 2); break }
start = -1
}
}
if (split === null) {
let min = Infinity
for (let x = from; x <= Math.round(w * 0.5); x++) { const b = busy(x); if (b < min) { min = b; split = x } }
console.warn(`No clean gap after the headshot; cutting at x=${split}, where they overlap least.`)
}
const crop = (left, width) =>
sharp(buf).extract({ left, top: 0, width, height: h }).jpeg({ quality: 92 }).toBuffer()
return { split, head: await crop(0, split), turnaround: await crop(split, w - split) }
}
const SHEET_PROMPT = `Character reference sheet, photorealistic photo, plain light-grey studio background, soft even lighting, no text, no labels, no watermark. Left third of the image: a large head-and-shoulders portrait (headshot) of the woman facing the camera. Right two-thirds: a full-body three-view turnaround of the same woman, front view, side view and back view, standing upright in a relaxed pose, evenly spaced, with clear space between the headshot and the turnaround. The woman is a 25-year-old American woman with a polished Instagram influencer look: soft glam makeup, glowing sun-kissed skin, groomed brows, glossy lips, a fit and slim figure. Long dark-brown hair in loose glossy waves. Outfit (a light summer date outfit that shows her shoulders, arms and legs but has a modest neckline, no cleavage): a white off-the-shoulder ribbed knit top, a high-waisted light-blue denim mini skirt, small gold hoop earrings, white low-top sneakers. Same face, same hair and same outfit in every view.`;
const VIDEO_PROMPT = `Reference images: image 1 is her headshot (face, features, hair, makeup); image 2 is a front / side / back three-view of the same woman in a light summer outfit (outfit, accessories, shoes, body). Keep exactly this face, hair and outfit in every frame. She is a 25-year-old woman with a polished Instagram look.
Outdoor café date. Scene: a sunny café terrace on a quiet European-style street, a small round marble table with two cappuccinos and a croissant, a striped awning, potted flowers, soft morning sun.
Camera: the viewer's point of view sitting across the small table, very close to her, eye level.
0.0–1.5 s: she rests her chin on her interlaced fingers, watching the lens with a slow half-smile.
1.5–3.0 s: she reaches across, takes the viewer's cappuccino instead of her own and sips it, eyes on the lens over the cup.
3.0–4.5 s: she wipes a little milk foam from her lip with her thumb and laughs.
4.5–6.0 s: she says, teasing: "Mine's better." and slides her own cup toward the camera.
6.0–8.0 s: she leans in close to the lens on her elbows, her bare shoulders catching the sun, lightly bites her lower lip and holds eye contact.
8.0–10.0 s: she leans back, tucks her hair behind her ear and gives one playful wink.
Lighting: soft warm morning sun, gentle highlights on her hair and shoulders.
Flirt cues (natural and playful, never explicit): long lingering eye contact with the lens, a slow half-smile, lightly biting her lower lip once, glancing down and then back up through her lashes, leaning in close to the lens as if sharing a secret, at most one playful wink. She is clearly enjoying the viewer's company.
Audio: light French café jazz and soft street ambience; her line is clear, natural American English; no other voices, no laugh track.
Style: realistic Instagram Reel shot on an iPhone, vertical 9:16, warm natural colors, crisp focus on her face, gentle handheld motion, one continuous take.
Avoid: changing her face or outfit, cleavage, lingerie, swimwear, see-through fabric, kissing, touching her own body suggestively, extra fingers, warped hands, strangers staring at the camera, on-screen text, subtitles, watermark.`;
// 1. One character sheet: $0.02.
const sheet = await wait("/v1/images/generations", await create("/v1/images/generations", {
model: "muse-image",
prompt: SHEET_PROMPT,
aspect_ratio: "2:1",
}));
const sheetBuf = Buffer.from(await (await fetch(sheet.resultUrls[0])).arrayBuffer());
await writeFile("sheet.jpg", sheetBuf);
// 2. Split it into two reference images. Open both files before going on.
const { split, head, turnaround } = await splitSheet(sheetBuf);
await writeFile("ref-head.jpg", head);
await writeFile("ref-turnaround.jpg", turnaround);
console.log(`sheet saved, cut at x=${split}`);
// 3. A 10-second vertical clip with both references: 10 x $0.037 = $0.37.
const dataUri = (b) => "data:image/jpeg;base64," + b.toString("base64");
const video = await wait("/v1/video/generations", await create("/v1/video/generations", {
model: "muse-video",
prompt: VIDEO_PROMPT,
images: [dataUri(head), dataUri(turnaround)],
duration: 10,
aspect_ratio: "9:16",
}));
await writeFile("clip.mp4", Buffer.from(await (await fetch(video.resultUrls[0])).arrayBuffer()));
console.log("saved clip.mp4");
Check the result
- Open
sheet.jpg: one woman, a headshot on the left, three full-body views on the right, no text. - Open the two crops: the whole face in
ref-head.jpg, all three views inref-turnaround.jpg. A sliver of the other half at the edge is fine. - Watch
clip.mp4: same face, hair and outfit as the sheet, 720 x 1280, and the spoken line from the prompt.
Errors you will probably hit
All of these come back as HTTP 400 with the reason in msg, before anything is generated or charged:
-
muse-video takes either a first frame (image_url) or reference images (images), not both: pick one. An array of images always means references. duration must be a whole number of seconds from 4 to 30muse-video supports 16:9 (1280x720) or 9:16 (720x1280) only — set aspect_ratio to 16:9 or 9:16muse-image generates one image per request (got n=2); submit one request per image-
muse-image does not support seed: results are not repeatable: there is no seed, which is the reason this tutorial exists.
Limits worth knowing before you build on it
- Muse Image: about 2.5 megapixels, no 4K, no transparent background, no negative prompt, no seed, no masks.
- Muse Video: 720p only, 16:9 or 9:16 only, at most four reference images, and either references or one first frame.
- Past ten seconds, Muse cuts to a new shot around the 10- and 20-second marks, so a 30-second clip is three shots.
- If the prompt states a total length that disagrees with
duration(say "about 7.5 seconds" on an 8-second request), Muse follows the prompt and the clip comes back shorter. - Not every request succeeds. Failures are not charged, but build a retry.
When not to use this
Skip it if you need 4K stills, a single unbroken shot longer than ten seconds, or guaranteed output on every request. And do not build the character from a real person's photos: an AI influencer should be invented, not copied from someone who never agreed to it.
The model pages list every parameter and limit: Muse Image on apimodels.app and Muse Video on apimodels.app.
How are you keeping a recurring character consistent across clips: reference sheets, first frames, or something else?


Top comments (0)