Build a Consistent AI Influencer with Muse Image and Muse Video in Node.js, $0.39 per Clip A developer at apimodels.app published a Node.js script that generates a consistent AI influencer character by first producing a single character sheet with Meta's Muse Image model and then feeding cropped headshot and turnaround references into Muse Video to render a 10-second vertical clip. Because Muse Image has no seed, the approach relies on a prompt requesting a headshot plus front, side and back turnaround in one image, with a heuristic splitSheet() function cutting the sheet at the first background gap; the author reports it landed inside the gap on three of four test sheets and within a few pixels of a hand cut on the fourth. Clips cost about $0.39 each, with 10-second renders typically finishing in one to two minutes. 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?