Mastering Runway Frames

3 min read • January 31, 2025

Table of contents

  1. Introduction
  2. Preparing Prompts
  3. Executing script
  4. Examples
  5. Conclusion

Introduction

In this article, we will show how to use an experimental Runway API for RunwayML to batch-generate images with Runway Frames. If you are an advanced user or perhaps want to utilize your time and efforts efficiently, and are a happy owner of Runway’s Unlimited subscription, this article is for you!

As you probably already figured out, it takes many attempts before you get your perfect image. This trial and error task can be very tedious and time-consuming. But it does not have to be, enter the bright and shiny world of Automation. With a simple script provided below and some basic file editing, you can cut out all the waiting time and get straight to picking the winners.

🕝On average, Runway takes about 20 to 30 seconds to complete a single Frames generation. Each generation produces four images. You can run up to two generations in parallel. With the script provided below, you can expect to complete around 100 generations per hour (about 400 images in total).

Preparing Prompts

Suppose you have a few text prompts for your project. The first step will be to create a file frames.json as shown below and edit it so it contains your Runway prompts. You can put as many prompts as you wish. No need to hold back, as you will not be running them manually, not anymore.

It may help to use ChatGPT, Claude or perhaps Gemini along with the Frames Prompting Guide and ask them to build prompts for you. This way, you can build a lot of prompts very quickly and get it going. Later, you can see what is working and what’s not, and refine them.

Expand frames.json
[
    {
        "text_prompt": "Your prompt goes here. By default exploreMode is ON."
    },
    {
        "text_prompt": "Your prompt goes here. You can optionally provide all the params supported by Frames, see https://useapi.net/docs/api-runwayml-v1/post-runwayml-frames-create",
        "style ": "terracotta",
        "exploreMode": false,
        "aspect_ratio ": "21:9",
        "diversity ": 5,
        "seed ": 987654321
    }
]

Executing script

The real magic starts here. We assume that you subscribed to useapi.net. Our subscription is essentially free when you consider the time you will save. It takes only a few minutes to set up Runway with our API. Finally, you can glance over the very detailed API documentation we provide. Each endpoint provides the ability to Try It right from the browser, for example Frames » Try It. If you don’t feel like reading the docs, that is fine too, the script below is all you need.

We will be using Node.js to execute JavaScript script provided below. Please download and install version 21 or older.

Create a file frames.mjs with the code provided below.

Your frames.json should be in the same folder as frames.mjs.

Finally, execute the script node ./frames.mjs API_TOKEN EMAIL where API_TOKEN is your useapi.net API token and EMAIL is configured Runway account email you want to use.

All generated images will be downloaded locally to your drive, so you can view them once they are ready.

Expand frames.mjs script
/*

Script version 1.0, January 31, 2025

Script to generate images using prompts with Runway API v1 by useapi.net 🚀
For more details visit https://useapi.net/docs/api-runwayml-v1

Installation Instructions:
==========================

You need Node.js v21 or newer installed to run this script. Download and install Node.js from:

- Windows, macOS, Linux: https://nodejs.org/

After installation, verify by running the following commands in a terminal:

   node -v

Running the Script:
===================

Usage: node frames.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]

Replace API_TOKEN with your actual useapi.net API token, see https://useapi.net/docs/start-here/setup-useapi
Replace EMAIL with configured Runway email account, see https://useapi.net/docs/start-here/setup-runwayml
If optional PROMPTS_FILE not provided frames.json will be used.

Example #1:
--------

node frames.mjs user:1234-abcdefhijklmnopqrstuv [email protected]

This command executes the script using API token user:1234-abcdefhijklmnopqrstuv with [email protected] Runway account email.

Example #2:
--------

node frames.mjs user:1234-abcdefhijklmnopqrstuv [email protected] frames.json

This command executes the script using API token user:1234-abcdefhijklmnopqrstuv with [email protected] Runway account email and load prompts from frames.json file.

*/

import readline from 'node:readline';
import fs from 'fs/promises';
import path from 'path';
import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';

// Constants
const RESULTS_FILE = 'frames_results.txt';
const ERRORS_FILE = 'frames_errors.txt';
const DEFAULT_PROMPTS_FILE = 'frames.json';
const SLEEP_429 = 5 * 1000; // in milliseconds
const SLEEP_DOWNLOAD = 10 * 1000; // in milliseconds
const MAX_CONCURRENT_JOBS = 3; // Number of parallel jobs
const DEFAULT_DIVERSITY = 3;

// Docs reference https://useapi.net/docs/api-runwayml-v1/get-runwayml-accounts
const urlAccounts = 'https://api.useapi.net/v1/runwayml/accounts';
// Docs reference https://useapi.net/docs/api-runwayml-v1/post-runwayml-frames-create
const urlFramesCreate = 'https://api.useapi.net/v1/runwayml/frames/create';
// Docs reference https://useapi.net/docs/api-runwayml-v1/get-runwayml-tasks-taskId
const urlTasks = 'https://api.useapi.net/v1/runwayml/tasks/';

// Utility to sleep for given milliseconds
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Function to fetch configured Runway API accounts
async function fetchAccounts(apiToken) {
    const response = await fetch(urlAccounts, {
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        }
    });

    if (!response.ok) {
        console.error(`⛔ Error fetching accounts (HTTP ${response.status}): ${response.statusText}`);
        process.exit(1);
    }

    return response.json();
}

async function submit(apiToken, url, body, index, text_prompt) {
    const createResponse = await fetch(url, {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        },
        body
    });

    const createBody = await createResponse.text();

    if (createResponse.status == 200) {
        const json = JSON.parse(createBody);
        const { taskId } = json;
        if (taskId) {
            await fs.appendFile(RESULTS_FILE, `${taskId},#${index}:${text_prompt}\n`);
            console.log(`✅ taskId`, taskId);
            return 200;
        } else {
            const error = `No taskId found in HTTP 200 response`;
            console.log(`❓ ${error}`, createBody);
            await fs.appendFile(ERRORS_FILE, `${error},#${index}:${text_prompt}\n`);
            return 500;
        }
    } else {
        switch (createResponse.status) {
            case 429:
                console.log(`🔄️ Retry on HTTP ${createResponse.status}`);
                break;
            case 422:
                console.log(`🛑 MODERATED prompt`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${text_prompt}\n`);
                break;
            case 412:
                console.log(`🛑 account run out of credits`, createBody);
                break;
            default:
                console.log(`❗ FAILED with HTTP ${createResponse.status}`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${text_prompt}\n`);
        }
        return createResponse.status;
    }
}

async function submitFrames(apiToken, email, prompt, index) {
    const { text_prompt, aspect_ratio, diversity, style, seed } = prompt;

    const exploreMode = prompt?.exploreMode ?? true;

    console.log(`🚀 Frames » Prompt #${index} • account ${email} • exploreMode ${exploreMode ? 'ON' : 'OFF'}`);

    const body = JSON.stringify({ email, text_prompt, aspect_ratio, diversity: diversity ?? DEFAULT_DIVERSITY, style, seed, exploreMode });

    return await submit(apiToken, urlFramesCreate, body, index, text_prompt);
}

// Function to download assets
async function download(apiToken) {
    if (! await fileExists(RESULTS_FILE)) return;

    try {
        const resultsContent = await fs.readFile(RESULTS_FILE, 'utf8');
        const lines = resultsContent.trim().split('\n');

        for (const line of lines) {
            const [taskId, prompt] = line.split(',');

            console.log(`👉 ${taskId}`);

            while (true) {
                const response = await fetch(`${urlTasks}${taskId}`, {
                    headers: {
                        'Accept': 'application/json',
                        'Authorization': `Bearer ${apiToken}`
                    }
                });

                if (!response.ok) {
                    console.log(`🛑 Download failed ${taskId} (HTTP ${response.status}):\n${prompt}\n`, await response.text());
                    break;
                }

                const taskResponseBody = await response.json();
                const { status, artifacts, error, progressRatio, estimatedTimeToStartSeconds } = taskResponseBody;

                if (status == 'FAILED') {
                    console.error(`🛑 FAILED ${taskId} (${error}):\n${prompt}\n`);
                    break;
                }

                if (status == 'SUCCEEDED') {
                    for (let i = 0; i < artifacts.length; i++) {
                        const url = artifacts[i].url;
                        const extension = path.extname(new URL(url).pathname);
                        const assetFileName = `${taskId}-${i}${extension}`;

                        try {
                            await fs.access(assetFileName);
                            console.log(`⚠️ ${assetFileName} already exists. Skipping download.`);
                            continue;
                        } catch {
                            // File does not exist, proceed with downloading
                        }

                        if (url) {
                            console.log(`✅ Downloading ${url} to ${assetFileName}`);
                            try {
                                const assetResponse = await fetch(url);
                                if (!assetResponse.ok) {
                                    console.error(`⛔ Unable to download ${taskId} (HTTP ${assetResponse.status}):\n${prompt}\n`, url);
                                    break;
                                }
                                const stream = Readable.fromWeb(assetResponse.body);
                                await writeFile(assetFileName, stream);
                            } catch (err) {
                                console.error(`⛔ Error during download: ${err}`);
                            }
                        } else {
                            console.error(`🛑 Unable to download ${taskId} status (${status} ${error}):\n${prompt}\n`);
                        }
                    }

                    break;
                }

                console.log(`⌛ ${taskId} status (${status}) and is still in progress (${progressRatio * 100}%, seconds to start ${estimatedTimeToStartSeconds}), waiting…`);
                await sleep(SLEEP_DOWNLOAD);
            }
        }
    } catch (error) {
        console.log(`⛔ Error during download:`, error.stack || error);
    }
}

// Main function
async function main() {
    const apiToken = process.argv[2];
    const email = process.argv[3];
    const promptFile = process.argv[4] || DEFAULT_PROMPTS_FILE;

    if (!apiToken || !email) {
        console.error('Usage: node frames.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]');
        process.exit(1);
    }

    console.info('Script v2.1');

    console.info('Node version is: ' + process.version);

    try {
        if (await fileExists(RESULTS_FILE)) {
            let user_input;
            while (!['y', 'n'].includes(user_input)) {
                user_input = (await promptUser(`❔ ${RESULTS_FILE} file detected. Do you want to download the results now? (y/n): `))?.toLowerCase();
                if (user_input == 'y') {
                    await download(apiToken);
                    await fs.unlink(RESULTS_FILE);
                }
            }
        }

        const start = new Date();
        try {
            console.info('START EXECUTION', start);
            await execute(apiToken, email, promptFile); // Pass the promptFile to execute function
        }
        finally {
            console.info('COMPLETED', new Date());
            console.info('EXECUTION ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }

        try {
            console.info('START DOWNLOAD', start);
            await download(apiToken);
        }
        finally {
            console.info('TOTAL ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }
    } catch (error) {
        console.error('⛔ Error during execution:', error.stack || error);
    }
}

// Modify the execute function to accept promptFile as a parameter
async function execute(apiToken, email, promptFile) {
    const accounts = await fetchAccounts(apiToken);

    console.info(`Configured Runway API accounts (${Object.values(accounts).length}):`, Object.values(accounts).map(a => a.email).join(', '));

    if (Object.values(accounts).length <= 0) {
        console.error(`⛔ No configured Runway accounts found. Please refer to https://useapi.net/docs/start-here/setup-runwayml`);
        process.exit(1);
    }

    if (!accounts[email]) {
        console.error(`⛔ Accounts ${email} not found. Please refer to https://useapi.net/docs/start-here/setup-runwayml`);
        process.exit(1);
    }

    if (accounts[email].error) {
        console.error(`⛔ Accounts ${email} has pending error. Please resolve and update account at https://useapi.net/docs/api-runwayml-v1/post-runwayml-accounts-email`);
        process.exit(1);
    }

    const promptData = await fs.readFile(promptFile, 'utf8');
    const prompts = JSON.parse(promptData);
    console.log(`Total number of prompts to process`, prompts.length);

    let warnings = [];

    const paramsFrames = ['text_prompt', 'aspect_ratio', 'diversity', 'style', 'seed', 'exploreMode'];

    const invalidKeys = (supported, prompt) => Object.keys(prompt).filter(key => !key.startsWith('__') && !supported.includes(key));

    for (let i = 1; i <= prompts.length; i++) {
        const prompt = prompts[i - 1];
        const { text_prompt } = prompt;

        const notSupported = invalidKeys(paramsFrames, prompt);

        if (notSupported.length)
            warnings.push(`⚠️  Frames » following params not supported: ${notSupported.join(',')}. Prompt ${i}`);

        if (!text_prompt)
            warnings.push(`⚠️  Frames » please specify text_prompt. Prompt ${i}`);
    }

    if (warnings.length > 0) {
        warnings.forEach(warning => console.warn(warning));
        console.error(`⛔ Execution stopped due to warnings.`);
        process.exit(1);
    }

    const queue = prompts.map((prompt, index) => ({ prompt, index }));

    const processQueue = async () => {
        const activeJobs = [];

        while (queue.length > 0) {
            if (activeJobs.length < MAX_CONCURRENT_JOBS) {
                const { prompt, index } = queue.shift();
                const job = (async () => {
                    while (true) {
                        const responseCode = await submitFrames(apiToken, email, prompt, index + 1);
                        if (responseCode == 429) {
                            await sleep(SLEEP_429);
                        } else if (responseCode == 412) {
                            process.exit(1);
                        } else {
                            break;
                        }
                    }
                })();
                activeJobs.push(job);
                job.finally(() => activeJobs.splice(activeJobs.indexOf(job), 1));
            } else {
                await Promise.race(activeJobs);
            }
        }

        await Promise.all(activeJobs);
    };

    await processQueue();
}

// Utility function to check if a file exists
async function fileExists(path) {
    try {
        await fs.access(path);
        return true;
    } catch {
        return false;
    }
}

// Function to prompt user input
async function promptUser(query) {
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });

    return new Promise((resolve) => rl.question(query, answer => {
        rl.close();
        resolve(answer);
    }));
}

function diffInMinutesAndSeconds(date1, date2) {
    const diffInSeconds = Math.floor((date2 - date1) / 1000);
    return `${Math.floor(diffInSeconds / 60)} minutes ${diffInSeconds % 60} seconds`;
};

main();

Examples

At a vintage carnival in the evening, a poised woman stands by a striped carousel. She wears a polka-dot tea-length dress in vibrant red, cinched at the waist, showcasing her hourglass figure. With softly curled auburn hair pinned back by a ribbon and bright, wide eyes, she exudes a classic elegance. The warm carnival lights cast a gentle glow against her porcelain skin, illuminating her cheerful smile as merry-go-round animals spin behind her.

With a stern expression, the flamboyant estate owner brandishes a skull he claims belonged to a former servant. His refined manner is shattered by a cruel demonstration, illustrating his twisted worldview. The foreign gentleman and his partner observe, forcibly maintaining composure while clenching their fists in secret anger.

A horse-drawn stagecoach cuts through a snowy Wyoming pass under ominous gray skies.
B&W

A rain-soaked street at night glimmers with neon reflections. A statuesque woman leans against a classic car, her figure wrapped in a sleek black trench coat and her keen cat-like eyes scanning her surroundings. Her iconic platinum-blonde hair frames a face with sharp cheekbones and red lips, while the city lights illuminate her pale complexion, creating a moody, suspenseful atmosphere as she clutches a hidden object under her coat.
High Contrast Cool

In a bustling train station at dusk, a determined Caucasian woman with light freckled skin, vivid blue eyes, and shoulder-length blonde hair styled in a neat bob stands beside a vintage steam engine. Her stylish yet practical trench coat outlines her slender figure while the golden glow of sunset and the steam swirling around create a scene of cinematic suspense and understated allure.
High Contrast Warm

Inside a bright 1950s-themed diner featuring vibrant neon signs and checkered floors, a slender, pale-skinned woman with a short black bob cut, wearing a white button-up shirt and cropped black pants, raises her arms as she prepares to dance. Opposite her stands a lean man with slicked-back dark hair and a thin black tie, clad in a sharp black suit, mimicking iconic dance moves. They sway together in a memorable twist contest, capturing a moment of spontaneous charm and playful rivalry.
Vivid Warm

Gasping for air, the bride claws through earthen walls inside a narrow wooden coffin. Her face is streaked with sweat and dirt, yet determination burns in her eyes. Each hammering punch against the coffin lid echoes her desperation to escape what seems like a living burial.

At a candlelit masquerade ball in an elegant manor, a captivating Eastern European beauty with porcelain skin, sapphire eyes, and long, flowing dark hair with subtle auburn highlights glides through the scene. Dressed in a rich burgundy velvet gown detailed with intricate lace, she exudes mystery and refined sensuality, as the soft flicker of candlelight dances on her delicate features.
B&W Contrast

A dramatic shootout erupts in the estate’s parlor, with bullets splintering ornate furniture and painting the walls. The freed man leaps over a fallen table, firing a revolver at the unsuspecting guards. In the midst of the chaos, the foreign gentleman’s calm presence and precise aim counter the swarm of attackers.

At a slim diner table with chipped cream-colored paint, six men in matching black suits, skinny ties, and sunglasses argue about tipping etiquette. The shortest of the group is particularly defensive, while the older gentleman with salt-and-pepper hair tries to keep the peace. Their camaraderie is fleeting, hinting at underlying tensions.

At a foggy, windswept harbor under an overcast sky, a resolute Nordic woman with fair, freckled skin, piercing ice-blue eyes, and platinum blonde hair arranged in a sophisticated braid stands tall. Dressed in a modern windbreaker with subtle Viking-inspired motifs, she faces the cold, crashing waves with a determined expression. The high-contrast cool lighting reinforces her heroic poise and inner strength.
High Contrast Cool

A sweltering jungle hideout dotted with flickering torches. A resolute woman, clad in a sweat-dappled camisole and rugged shorts, presses her back against a crumbling wall. The parted neckline reveals a light sheen across her clavicles as she exhales in relief. A partner joins her, close enough that their ragged breathing mingles in the low firelight. Her eyes dart from the carved stone idol to those insistent hands that anchor her waist, capturing the forbidden thrill of danger interwoven with an intense, fleeting desire.
Dark Anime

In a flamboyant carnival parade wending its way down narrow streets, a proud samba dancer twirls at the procession’s front. Her sequined costume, bursting with feather plumes in emerald and fuchsia, shimmers under strings of festival lanterns overhead. Bronze arms and legs move in hypnotic patterns, energizing the beating drums that match her joyful heartbeat. A bejeweled headdress sways precariously yet gracefully above her luminous smile. Towering confetti cannons explode in a shower of color, and the throng claps in unison, enthralled by her radiant spirit celebrating life’s vibrancy under the starry sky of the night festival.
Vivid Warm

In a 1970s roller rink awash with neon strobes, a confident woman gracefully skates in hypnotic circles. She wears glittery spandex flares that hug her curves, and a matching halter top revealing toned arms. Her Afro hairstyle, voluminous and bold, casts soft shadows beneath the pulsing disco lights. She glides effortlessly across the checkered floor, weaving around smaller clusters of skaters in a rhythmic sway. The funky bassline resonates under the swirling disco ball overhead, each note fueling her smooth movements. Iridescent beads around her neck clack softly with her momentum, accenting her cool, unshakeable poise.
In Motion

From the rafters overhead, blood drips onto the wooden floorboards, hinting at a hidden casualty. The bounty hunter steps back, revolver in hand, scanning the ceiling for the source. In this suffocating environment, paranoia pushes each person to suspect the others of orchestrating the carnage.

In a velvet-padded booth of a dimly lit speakeasy, a sharp-eyed lounge singer leans over a small round table. Her strappy black dress reveals the smooth curve of her back, each breath shifting the feathers nested in her short hairstyle. A single spotlight from the stage glances across her sequined neckline. Smoke drifts through the golden ambiance, and the hush from surrounding patrons hints at the layered tension—her gaze is locked in a private rendezvous, her entire posture promising illicit secrets hidden behind each melodic note.
B&W

Conclusion

Visit our Discord Server, Telegram Channel or Reddit Group for any support questions and concerns.

We regularly post guides and tutorials on the YouTube Channel.

Check our GitHub repo with code examples.