{"slug": "build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39", "title": "Build a Consistent AI Influencer with Muse Image and Muse Video in Node.js, $0.39 per Clip", "summary": "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.", "body_md": "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.\n\nMuse 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.\n\nDisclosure: 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.\n\n`fetch` and top-level `await`).` sharp` for cropping: `npm install sharp`.` APIMODELS_API_KEY`.\nInstead 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.\n\nThree things in the prompt matter more than the rest:\n\nMuse 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.\n\nWe 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.\n\n`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.\n\nThe 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.\n\n`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.\n\n```\n// muse-influencer.mjs — Node 18+. Install: npm install sharp\n// Run:     APIMODELS_API_KEY=your_key node muse-influencer.mjs\nimport sharp from \"sharp\";\nimport { writeFile } from \"node:fs/promises\";\n\nconst API = \"https://api.apimodels.app\";\nconst H = { Authorization: `Bearer ${process.env.APIMODELS_API_KEY}`, \"Content-Type\": \"application/json\" };\n\n// Create a task and return its id. 4xx responses carry the reason in msg.\nasync function create(path, body) {\n  const r = await fetch(API + path, { method: \"POST\", headers: H, body: JSON.stringify(body) });\n  const j = await r.json();\n  if (!r.ok || j.code !== 200) throw new Error(`${r.status}: ${j.msg}`);\n  return j.data.taskId;\n}\n\n// Poll until the task finishes. Failed tasks are not charged.\nasync function wait(path, taskId, everyMs = 5000, limitMs = 10 * 60_000) {\n  for (const until = Date.now() + limitMs; Date.now() < until; ) {\n    const { data } = await (await fetch(`${API}${path}?task_id=${taskId}`, { headers: H })).json();\n    if (!data) throw new Error(`unknown task ${taskId}`);\n    if (data.state === \"completed\") return data;\n    if (data.state === \"failed\") throw new Error(`${data.failMsg ?? \"failed\"} (not charged)`);\n    await new Promise((r) => setTimeout(r, everyMs));\n  }\n  throw new Error(`still running after ${limitMs / 1000}s: ${taskId}`);\n}\n\n// Split a Muse character sheet into the headshot (left) and the turnaround (right).\n// The cut goes into the first background gap right of the headshot; if the headshot\n// touches the turnaround, it goes where they overlap least. Always look at both crops.\nasync function splitSheet(buf) {\n  const { data, info } = await sharp(buf).greyscale().raw().toBuffer({ resolveWithObject: true })\n  const { width: w, height: h } = info\n  // Background grey = median of a thin strip along the top of the right half.\n  const strip = []\n  for (let y = 0; y < Math.max(4, Math.round(h * 0.03)); y++)\n    for (let x = Math.round(w / 2); x < w; x++) strip.push(data[y * w + x])\n  strip.sort((a, b) => a - b)\n  const bg = strip[strip.length >> 1]\n  // Share of a column's pixels that are clearly not background.\n  const busy = (x) => {\n    let n = 0\n    for (let y = 0; y < h; y++) if (Math.abs(data[y * w + x] - bg) > 25) n++\n    return n / h\n  }\n  const from = Math.round(w * 0.3)\n  const to = Math.round(w * 0.55)\n  let split = null\n  for (let x = from, start = -1; x <= to; x++) {\n    if (busy(x) < 0.05) { if (start < 0) start = x }\n    else if (start >= 0) {\n      if (x - start >= Math.max(8, Math.round(w * 0.005))) { split = Math.round((start + x) / 2); break }\n      start = -1\n    }\n  }\n  if (split === null) {\n    let min = Infinity\n    for (let x = from; x <= Math.round(w * 0.5); x++) { const b = busy(x); if (b < min) { min = b; split = x } }\n    console.warn(`No clean gap after the headshot; cutting at x=${split}, where they overlap least.`)\n  }\n  const crop = (left, width) =>\n    sharp(buf).extract({ left, top: 0, width, height: h }).jpeg({ quality: 92 }).toBuffer()\n  return { split, head: await crop(0, split), turnaround: await crop(split, w - split) }\n}\n\nconst 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.`;\n\nconst 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.\n\nOutdoor 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.\nCamera: the viewer's point of view sitting across the small table, very close to her, eye level.\n0.0–1.5 s: she rests her chin on her interlaced fingers, watching the lens with a slow half-smile.\n1.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.\n3.0–4.5 s: she wipes a little milk foam from her lip with her thumb and laughs.\n4.5–6.0 s: she says, teasing: \"Mine's better.\" and slides her own cup toward the camera.\n6.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.\n8.0–10.0 s: she leans back, tucks her hair behind her ear and gives one playful wink.\nLighting: soft warm morning sun, gentle highlights on her hair and shoulders.\nFlirt 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.\n\nAudio: light French café jazz and soft street ambience; her line is clear, natural American English; no other voices, no laugh track.\nStyle: realistic Instagram Reel shot on an iPhone, vertical 9:16, warm natural colors, crisp focus on her face, gentle handheld motion, one continuous take.\nAvoid: 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.`;\n\n// 1. One character sheet: $0.02.\nconst sheet = await wait(\"/v1/images/generations\", await create(\"/v1/images/generations\", {\n  model: \"muse-image\",\n  prompt: SHEET_PROMPT,\n  aspect_ratio: \"2:1\",\n}));\nconst sheetBuf = Buffer.from(await (await fetch(sheet.resultUrls[0])).arrayBuffer());\nawait writeFile(\"sheet.jpg\", sheetBuf);\n\n// 2. Split it into two reference images. Open both files before going on.\nconst { split, head, turnaround } = await splitSheet(sheetBuf);\nawait writeFile(\"ref-head.jpg\", head);\nawait writeFile(\"ref-turnaround.jpg\", turnaround);\nconsole.log(`sheet saved, cut at x=${split}`);\n\n// 3. A 10-second vertical clip with both references: 10 x $0.037 = $0.37.\nconst dataUri = (b) => \"data:image/jpeg;base64,\" + b.toString(\"base64\");\nconst video = await wait(\"/v1/video/generations\", await create(\"/v1/video/generations\", {\n  model: \"muse-video\",\n  prompt: VIDEO_PROMPT,\n  images: [dataUri(head), dataUri(turnaround)],\n  duration: 10,\n  aspect_ratio: \"9:16\",\n}));\nawait writeFile(\"clip.mp4\", Buffer.from(await (await fetch(video.resultUrls[0])).arrayBuffer()));\nconsole.log(\"saved clip.mp4\");\n```\n\n`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.\nAll of these come back as HTTP 400 with the reason in `msg`, before anything is generated or charged:\n\n`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.\nSkip 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.\n\nThe 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).\n\nHow are you keeping a recurring character consistent across clips: reference sheets, first frames, or something else?", "url": "https://wpnews.pro/news/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39", "canonical_source": "https://dev.to/super_lewis/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-nodejs-039-per-clip-2cl0", "published_at": "2026-11-02 00:43:20+00:00", "updated_at": "2026-11-02 00:51:58.564782+00:00", "lang": "en", "topics": ["generative-ai", "ai-tools", "ai-products", "developer-tools"], "entities": ["Meta", "Muse Image", "Muse Video", "apimodels.app", "Node.js", "sharp"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39", "markdown": "https://wpnews.pro/news/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39.md", "text": "https://wpnews.pro/news/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39.txt", "jsonld": "https://wpnews.pro/news/build-a-consistent-ai-influencer-with-muse-image-and-muse-video-in-node-js-0-39.jsonld"}}