Appearance
Rebrand a website from one command
A rebrand is a hundred small pieces of media: a hero image, section stills, an explainer voiceover, a launch film and a track under it. Every one of those is a call to the Cinara API. This page gives you a script that makes the whole set from a short brief, saves the files into a folder, and writes a manifest.json your site's build can read.
You run it once per brand. Change the brief, run it again, and you have the next version.
What you get
For the brief below, one run makes:
| File | Made with | Endpoint |
|---|---|---|
hero-1.jpg, hero-2.jpg | Images · Frame, 16:9 | POST /v1/images |
section-1.jpg … one per section | Images · Frame, 4:3 | POST /v1/images |
social.jpg | Images · Frame, 9:16 | POST /v1/images |
hero-film.mp4 | Video · Scene, 16:9, with sound | POST /v1/video |
voiceover.mp3 | Speech, in the brand's voice | POST /v1/speech |
music-bed.mp3 | Music · Standard instrumental | POST /v1/music |
manifest.json | — | — |
Before you start
An API key. In the app, open API Keys and create one named after the project. Every generation the script makes is logged under that key in History, so you can see exactly what the run produced and what it cost.
Node.js 18 or later. The script uses the built-in
fetch, so there is nothing to install.Enough credits. The brief below costs 1,825 credits for the whole set. Every failed generation is refunded.
Piece Credits 2 hero stills (Frame, 16:9) 260 3 section stills (Frame, 4:3) 360 1 social still (Frame, 9:16) 130 10-second hero film (Scene) 630 30-second music bed (Standard instrumental) 300 Voiceover (145 characters) 145 Total 1,825 A brand voice (optional). Design it once in the app with Voice Design, save it, and copy its voice id from Voice Library. Put that id in the brief. Without one, the brief uses a library voice.
Why the voice is designed once
A brand voice is a decision, not a per-run setting. Designing it in the app lets you listen to a few before you choose, and every later run speaks with the one you kept.
1. Write the brief
Save this as brand.json and change every value. The brand below is fictional.
json
{
"name": "Juniper & Oak",
"look": "warm natural light, soft film grain, earthy greens and oak browns, calm and unhurried, editorial photography",
"hero": "a small coffee roastery at sunrise, steam rising from a freshly poured cup on an oak counter, window light",
"sections": [
"hands weighing green coffee beans on a brass scale",
"a barista pouring latte art in a sunlit café",
"a paper bag of coffee beans on a kitchen table beside a French press"
],
"social": "a single cup of coffee on an oak table, shot from above, lots of empty space at the top for a headline",
"film": "slow push-in across an oak counter toward a steaming cup of coffee at sunrise, dust in the window light, gentle and cinematic",
"filmSeconds": 10,
"voiceId": "velvet",
"voiceover": "Some mornings deserve to take their time. Juniper and Oak roasts in small batches, by hand, so every cup tastes like the morning it was made for.",
"music": "warm acoustic guitar and soft piano, hopeful and unhurried, 85 bpm, no vocals",
"musicSeconds": 30
}| Field | What it's for |
|---|---|
look | Added to every image and the film, so the whole set shares one style. |
hero, sections, social | What each still shows. Leave look out of these; the script adds it. |
film, filmSeconds | The hero film. filmSeconds is 5–15 for Scene. |
voiceId | A library voice id such as velvet, or your designed voice's id. |
voiceover | The words, exactly as they should be read. |
music, musicSeconds | The bed's style and exact length, 10–120 seconds. |
Library voices that suit brand work: velvet (polished), canyon (low and cinematic), confetti (big launch energy), kindred (easy and approachable). The full list is in Speech.
2. Save the script
Save this as rebrand.mjs next to brand.json.
js
// rebrand.mjs — makes a site's media from brand.json with the Cinara API.
// Usage: CINARA_API_KEY=cin_... node rebrand.mjs [brand.json]
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const API = "https://api.cinara.ai";
const KEY = process.env.CINARA_API_KEY;
if (!KEY) throw new Error("Set CINARA_API_KEY to an API key from app.cinara.ai/api-keys");
const brief = JSON.parse(await readFile(process.argv[2] ?? "brand.json", "utf8"));
const slug = brief.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const out = join("rebrand-output", slug);
await mkdir(out, { recursive: true });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** One API call. Waits and retries when the key's 120-a-minute limit is reached. */
async function call(method, path, body) {
for (;;) {
const response = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "User-Agent": "cinara-rebrand/1.0" },
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 429) {
await sleep((Number(response.headers.get("Retry-After")) || 10) * 1000);
continue;
}
const data = await response.json();
if (!response.ok) throw new Error(`${method} ${path}: ${data.message ?? response.status}`);
return data;
}
}
/** Starts a job, then checks it every few seconds until it has finished. */
async function job(label, path, body) {
const started = await call("POST", path, body);
const id = started.generation.id;
console.log(` started ${label}`);
for (;;) {
await sleep(5000);
const result = await call("GET", `${path}/${id}`);
if (result.generation.status === "succeeded") {
console.log(` ready ${label}`);
return result;
}
if (result.generation.status !== "running") throw new Error(`${label}: ${result.generation.error ?? result.generation.status}`);
}
}
/** Media links in a finished job are signed and short-lived, so files are saved straight away. */
async function save(url, file) {
const response = await fetch(url, { headers: { "User-Agent": "cinara-rebrand/1.0" } });
if (!response.ok) throw new Error(`Download failed for ${file}: ${response.status}`);
await writeFile(join(out, file), Buffer.from(await response.arrayBuffer()));
return file;
}
const styled = (subject) => `${subject}. ${brief.look}`;
const manifest = { brand: brief.name, madeAt: new Date().toISOString(), madeBy: "cinara.ai", files: {} };
console.log(`Rebranding ${brief.name} → ${out}`);
// Everything that takes a while starts at once; the voiceover finishes in the same request.
const [hero, sections, social, film, music, voiceover] = await Promise.all([
job("hero stills", "/v1/images", { model: "frame", prompt: styled(brief.hero), aspectRatio: "16:9", count: 2 }),
Promise.all(brief.sections.map((section, index) => job(`section ${index + 1}`, "/v1/images", { model: "frame", prompt: styled(section), aspectRatio: "4:3", count: 1 }))),
job("social still", "/v1/images", { model: "frame", prompt: styled(brief.social), aspectRatio: "9:16", count: 1 }),
job("hero film", "/v1/video", { model: "scene", mode: "text", prompt: styled(brief.film), seconds: brief.filmSeconds ?? 10, aspectRatio: "16:9" }),
job("music bed", "/v1/music", { kind: "instrumental", tier: "standard", style: brief.music, seconds: brief.musicSeconds ?? 30, title: `${brief.name} bed` }),
call("POST", "/v1/speech", { text: brief.voiceover, model: "speech", voiceId: brief.voiceId, format: "mp3_high" }),
]);
manifest.files.hero = await Promise.all(hero.files.map((file, index) => save(file.url, `hero-${index + 1}.jpg`)));
manifest.files.sections = await Promise.all(sections.map((section, index) => save(section.files[0].url, `section-${index + 1}.jpg`)));
manifest.files.social = await save(social.files[0].url, "social.jpg");
manifest.files.film = await save(film.files.find((file) => file.name === "video").url, "hero-film.mp4");
manifest.files.music = await save(music.files[0].url, "music-bed.mp3");
// Speech returns the finished generation; its audio is read with the key.
const audio = await fetch(`${API}/v1/generations/${voiceover.generation.id}/file`, { headers: { Authorization: `Bearer ${KEY}`, "User-Agent": "cinara-rebrand/1.0" } });
if (!audio.ok) throw new Error(`Voiceover download failed: ${audio.status}`);
await writeFile(join(out, "voiceover.mp3"), Buffer.from(await audio.arrayBuffer()));
manifest.files.voiceover = "voiceover.mp3";
manifest.creditsLeft = voiceover.credits;
await writeFile(join(out, "manifest.json"), JSON.stringify(manifest, null, 2));
console.log(`Done. ${Object.values(manifest.files).flat().length} files in ${out}`);3. Run it
bash
CINARA_API_KEY=cin_your_key node rebrand.mjs brand.jsontext
Rebranding Juniper & Oak → rebrand-output/juniper-oak
started hero stills
started section 1
started section 2
started section 3
started social still
started hero film
started music bed
ready section 2
ready hero stills
…
Done. 9 files in rebrand-output/juniper-oakImages are usually ready in under a minute. The film and the music take a few minutes. The script waits for all of them.
4. Use the files in your site
manifest.json names every file, so a build step can pick them up without knowing what the run made:
json
{
"brand": "Juniper & Oak",
"madeAt": "2026-09-16T08:12:40.118Z",
"madeBy": "cinara.ai",
"files": {
"hero": ["hero-1.jpg", "hero-2.jpg"],
"sections": ["section-1.jpg", "section-2.jpg", "section-3.jpg"],
"social": "social.jpg",
"film": "hero-film.mp4",
"music": "music-bed.mp3",
"voiceover": "voiceover.mp3"
},
"creditsLeft": 41250
}Copy the folder into your site's public assets, point your hero component at files.film and files.hero[0], and set files.social as the page's social image.
Make it yours
- More languages. Run the voiceover again with a translated
voiceoverandlanguageset (for example"language": "Hindi"), or sendhero-film.mp4to Dubbing. - A launch cutdown. Add a second video job with
"aspectRatio": "9:16"and"seconds": 5for the ad account. - Captions. Send the voiceover to Alignment with the exact script to get word-timed subtitles.
- Keep a folder in Cinara. Everything the key made is also in History in the app, and can be moved into a Drive folder to share with a client.
Good to know
- You're responsible for what you publish. Prompts that name well-known people or brands you don't own are refused. Check the output before it goes live.
- Links expire. The
urlin a finished job is signed and short-lived. The script saves files as soon as each job finishes; don't store the links. - Failures refund. If a job fails, its credits come back, and the script stops with the reason. Fix the brief and run it again.
- Rate limit. Each key can make 120 requests a minute, polling included. The script waits when it reaches the limit.
Related
- API overview — keys, errors and limits
- Images · Video · Speech · Music
- Advertising campaigns — the same pieces, made for a campaign instead of a site