# Build a Consistent AI Influencer with Muse Image and Muse Video in Node.js, $0.39 per Clip

> Source: <https://dev.to/super_lewis/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-nodejs-039-per-clip-2cl0>
> Published: 2026-11-02 00:43:20+00:00

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.

`fetch` and top-level `await`).` sharp` for cropping: `npm install sharp`.` APIMODELS_API_KEY`.
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:

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.

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.

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.

```
// 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");
```

`sheet.jpg`: one woman, a headshot on the left, three full-body views on the right, no text.`ref-head.jpg`, all three views in `ref-turnaround.jpg`. A sliver of the other half at the edge is fine.` clip.mp4`: same face, hair and outfit as the sheet, 720 x 1280, and the spoken line from the prompt.
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 30``muse-video supports 16:9 (1280x720) or 9:16 (720x1280) only — set aspect_ratio to 16:9 or 9:16``muse-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.`duration` (say "about 7.5 seconds" on an 8-second request), Muse follows the prompt and the clip comes back shorter.
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](https://apimodels.app/models/muse-image) and [Muse Video on apimodels.app](https://apimodels.app/models/muse-video).

How are you keeping a recurring character consistent across clips: reference sheets, first frames, or something else?
