GitHub

Advanced guides

Airbnb "Made possible by hosts" Commercials

Introduction

In early 2022, Airbnb released an assortment of iconic video advertisements as part of their Made possible by hosts campaign. Each of these videos tells the story of a beautiful vacation through the lens of a series of images subtly fading in and out to music, punctuated by feel-good quotes. This video can be easily templatized and automated using Editframe!

Project Goals

Today, we're going to demonstrate how to recreate and templatize one of these videos using editframe-js. By the end of this guide, you will be able to dynamically create videos in this style by calling a function with the specific photos and text that you would like to use.

Original Video

Here is the particular video we will emulate:

Templatized Video

Here is an example of the output of our template using photos from my recent trip to Miami:

Setting up an environment

First, let's set up our development environment. We'll use Replit, a free in-browser IDE. We've created a repl with Node.js and Editframe's JavaScript library installed and ready to go.

  1. Open the @editframe/airbnb-made-possible-by-hosts repl
  2. Click Fork repl (you'll be asked to create a Replit account if you don't have one)
  3. If you haven't already, you'll need to create an Editframe account
  4. In the repl, click the 🔒 Secrets (Environment variables) tab on the left
  5. Add the following secrets. You can find the values in the Editframe dashboard:
keyvalue
EDITFRAME_CLIENT_IDClient ID
EDITFRAME_API_TOKENProduction API Token

Your credentials will now be available as properties of process.env (for example: process.env["EDITFRAME_CLIENT_ID"]), and they're safe because they're not saved in the source code of your repl. See Replit's docs for secrets and environment variables for more on how secrets work.

That's it! We now have a Node.js development environment with Editframe's JavaScript library installed. Let's make a video!

Note

You'll notice that the repl contains a file called final.js. This file contains the full working example code you'll have if you complete this guide. If you learn better by exploring source code, feel free to play around with final.js. To execute final.js, open the Shell tab and run node ./final.js.

Configuring the client

Let's configure the editframe-js client. Open index.js and add the following code:

import { Editframe } from "@editframe/editframe-js";


const editframe = new Editframe({
  clientId: process.env["EDITFRAME_CLIENT_ID"],
  develop: true,
  token: process.env["EDITFRAME_API_TOKEN"],
});

Creating a composition

Now that we've successfully authenticated our editframe-js client, we can instantiate a composition that we will use to build our video. By default, the backgroundColor is black, and since we will use the composition.addSequence() method to automatically sequence our layers, we don't have to pass a duration. All we need to provide is the intended resolution of our final video, which is 1080p, or 1920x1080.

// ...
const editframe = new Editframe({
  clientId: process.env["EDITFRAME_CLIENT_ID"],
  token: process.env["EDITFRAME_API_TOKEN"],
});


// Add this code:
// highlight-start
const composition = await editframe.videos.new({
  dimensions: {
    height: 1080,
    width: 1920,
  },
});
// highlight-end

Analyzing the original video

Let's take a minute to analyze the video we're trying to emulate. We have in the following order:

  1. An intro image that fades in.
  2. A series of images that cut from one to another without a transition.
  3. A line of text with the name of the location.
  4. A line of text that fades in below the location shortly after.
  5. A series of images that fade in and out to black with a moment of black space between.
  6. Two back-to-back lines of text with a cute catch-phrase.
  7. A line of text taking credit for making this video possible 😉
  8. A logo.
  9. About 10 seconds of empty space for adding stuff like Youtube links.

Preparing our Ingredients

Since we've identified the names of the assets and text lines that we need, let's set them up as variables so we can reference them easily in our code. Feel free to replace the images and text with your own content!

// ...
const composition = await editframe.videos.new({
  dimensions: {
    height: 1080,
    width: 1920,
  },
});


// Add this code:
// highlight-start
const audio = "https://editframe.com/docs/guides/airbnb-commercial/arlae.mp3";
const introImage = "https://editframe.com/docs/guides/airbnb-commercial/together.jpg";
const part1Images = [
  "https://editframe.com/docs/guides/airbnb-commercial/ocean-drive.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/juice.jpg",
];
const part2Images = [
  "https://editframe.com/docs/guides/airbnb-commercial/crab.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/street-art.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/tree.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/sushi.jpg",
];
const spacerImage = "https://editframe.com/docs/guides/airbnb-commercial/spacer.jpg";
const logoImage = "https://editframe.com/docs/guides/airbnb-commercial/editframe-logo.png";
const locationText = "Miami Beach";
const hostText = "Jean Carlos";
const catchphraseText = "Something to write home about";
const catchphraseText2 = "Made possible by Editframe";
// highlight-end

Layer Cake

We've got all the ingredients ready, it's time to start mixing the batter! Let's add all our images and text to the composition!

Gather the Tools

For this recipe, we'll need four main tools.

  1. We'll need the composition.addImage() method for creating the image layers.
  2. We'll need the composiiton.addText() method for creating the text layers.
  3. We'll need the composition.addSequence() method to automatically sequence all our layers so we don't have to do any math to calculate their start times.
  4. We'll need the composition.addAudio() method to include a nice soundtrack to accompany our images!

Intro Image

Let's start at the beginning, with our first image, the introImage. Here are the requirements:

  • The image needs to display for 3 seconds. As images have no inherent duration, by default, all images take up the full duration of their composition. We can supply a trim configuration object to trim our image layers to 3 seconds.
  • The image needs to be horizontally and vertically centered in the composition. We can supply a position configuration object to accomplish this.
  • The image needs padding on the top and bottom. We can supply a size configuration object specifying that the height of our layer should equal the height of the composition minus some fixed value. The width will automatically scale down with the height while maintaining the same aspect ratio.
  • The image needs to fade in. We can supply a transitions configuration object to specify a fadeIn.
// ...
const hostText = "Jean Carlos";
const catchphraseText = "Something to write home about";
const catchphraseText2 = "Made possible by Editframe";


// Add this code:
// highlight-start
const introImageLayer = await composition.addImage(introImage, {
  position: {
    x: "center",
    y: "center",
  },
  size: {
    height: composition.dimensions.height - 150,
  },
  transitions: [
    {
      duration: 3,
      type: "fadeIn",
    },
  ],
  trim: {
    end: 3,
  },
});
// highlight-end

Part 1 Images

Our next group of images all follow a shared set of rules, and so we can easily add them each in a loop.

// ...
const introImageLayer = await composition.addImage(introImage, {
  // ...
});


// Add this code:
// highlight-start
const part1ImageLayers = [];


for (const url of part1Images) {
  const image = await composition.addImage(url, {
    position: {
      x: "center",
      y: "center",
    },
    size: {
      height: composition.dimensions.height - 150,
    },
    trim: {
      end: 3,
    },
  });


  part1ImageLayers.push(image);
}
// highlight-end

Part 2 Images

The final group of images also all follow a slightly different set of rules in that they need to fade in and out. In addition, there is supposed to be a brief moment of blackness between each fade. We can accomplish this by adding a 0.75 second spacer in between each image.

By returning [image, spacer] in each iteration of our loop, we'll end up with a multidimensional array, so we flatten it with a call to .concat() to get our list of [image, spacer, image, spacer, etc...].

Add the following code:

// ...
for (const url of part1Images) {
  // ...
  part1ImageLayers.push(image);
}


// Add this code:
// highlight-start
let part2ImageLayers = [];


for (const url of part2Images) {
  const image = await composition.addImage(url, {
    position: {
      x: "center",
      y: "center",
    },
    size: {
      height: composition.dimensions.height - 150,
    },
    transitions: [
      {
        duration: 3,
        type: "fadeIn",
      },
      {
        duration: 3,
        type: "fadeOut",
      },
    ],
    trim: {
      end: 3,
    },
  });


  const spacer = await composition.addImage(spacerImage, {
    trim: {
      end: 0.75,
    },
  });


  part2ImageLayers = part2ImageLayers.concat([image, spacer]);
}
// highlight-end

DRYing up our cake

There are still a few image layers and all the text layers to set up, but lets take a quick step back. By now you've likely noticed that there is a lot of repetition in our image layer code, with only slight variations between image types. Some images are displayed for longer than others, some fade in, some fade out, and some are resized. The same goes for the text.

We can keep our code very simple by making some functions for abstracting away the editframe-js client code. These new functions will create these image layers and text layers and dynamically modify their configuration.

Add this code before the existing layer code:

// ...
const hostText = "Jean Carlos";
const catchphraseText = "Something to write home about";
const catchphraseText2 = "Made possible by Editframe";


// Add this code:
// highlight-start
const makeImage = async ({
  duration,
  fadeIn = false,
  fadeOut = false,
  resizeImage = true,
  url,
}) => {
  const image = await composition.addImage(url, {
    position: {
      x: "center",
      y: "center",
    },
    trim: {
      end: duration,
    },
  });


  if (resizeImage) {
    image.setHeight(composition.dimensions.height - 150);
  }


  if (fadeIn) {
    image.addTransition({ duration: 1, type: "fadeIn" });
  }


  if (fadeOut) {
    image.addTransition({ duration: 1, type: "fadeOut" });
  }


  return image;
};


const makeText = ({
  duration = 3,
  isRelative = false,
  text,
  fontSize = 70,
  y = "center",
}) =>
  composition.addText(
    {
      color: "#ffffff",
      fontFamily: "Montserrat",
      fontSize,
      fontWeight: 500,
      text,
      textAlign: "center",
    },
    {
      position: {
        isRelative,
        x: "center",
        y,
      },
      size: {
        height: 0,
      },
      trim: {
        end: duration,
      },
    }
  );
// highlight-end


const introImageLayer = await composition.addImage(introImage, {
  // ...
});
// ...

The rest of the cake

With these new makeImage and makeText functions, the layer-adding portion of our code can now be considerably less verbose. Let's refactor the existing layers to use these new functions and then add the rest of the image and text layers to our composition.

Replace the existing layer code with this code:

//...
const makeText = ({
  // ...
}) =>
  composition.addText(
    // ...
  );


// Replace the existing layer code with this code:
// highlight-start
const introImageLayer = await makeImage({
  duration: 3,
  fadeIn: true,
  url: introImage,
});


const part1ImageLayers = [];
let part2ImageLayers = [];


for (const url of part1Images) {
  part1ImageLayers.push(await makeImage({ duration: 3, url }));
}


for (const url of part2Images) {
  const image = await makeImage({
    duration: 3,
    fadeIn: true,
    fadeOut: true,
    url,
  });
  const spacer = await makeImage({ duration: 0.75, url: spacerImage });


  part2ImageLayers = part2ImageLayers.concat([image, spacer]);
}


const locationTextLayer = makeText({
  isRelative: true,
  text: locationText,
  y: 0.45,
});


const hostedByTextLayer = makeText({
  duration: 2,
  fontSize: 50,
  isRelative: true,
  text: `Hosted by ${hostText}`,
  y: 0.55,
});


const catchphraseTextLayer = makeText({
  duration: 2,
  text: catchphraseText,
});


const catchphraseTextLayer2 = makeText({
  duration: 2,
  text: catchphraseText2,
});


const logoLayer = await makeImage({
  duration: 3,
  resizeImage: false,
  url: logoImage,
});


const spacerEndLayer = await makeImage({
  duration: 10,
  url: spacerImage,
});
// highlight-end

Sequencing the layers

Now that we've added all our layers to the composition, it's time to put them in order! By default, all layers have a start time of 0, which means that if we ran the code right now, all of our images and text would be overlapped on top of each other. If you wanted to, you could totally flex your superb math skills and calculate and set all the start times for each layer. Or, you could just sequence them all automatically with a call to composition.addSequence().

Here we pass an array of our layers to the addSequence call, spreading in the part1ImageLayers and part2ImageLayers that have dynamic lengths.

// ...
const spacerEndLayer = await makeImage({
  duration: 10,
  url: spacerImage,
});


// Add this code:
// highlight-start
await composition.addSequence([
  introImageLayer,
  ...part1ImageLayers,
  locationTextLayer,
  ...part2ImageLayers,
  catchphraseTextLayer,
  catchphraseTextLayer2,
  logoLayer,
  spacerEndLayer,
]);
// highlight-end

Finishing touches

hostedByTextLayer

Astute readers may have noticed that we created the hostedByTextLayer but did not include it in the call to addSequence(). You can rest assured that this was intentional! The addSequence method simply puts a one-dimensional array of layers in order, one after another, so if you need two layers to co-exist in the same frames, you need to use additional tools.

In our case, we want the hostedByTextLayer to fade in below the locationTextLayer shortly after it appears, and we want them both to disappear at the same time. Lucky for us, the call to addSequence updates the start attributes of each of its sequenced layers. This means we can very easily accomplish this task by setting the start of the hostedByTextLayer to the start of the locationTextLayer plus a short 1 second offset.

// ...
await composition.addSequence([
  // ...
]);


// Add this code:
// highlight-start
hostedByTextLayer.setStart(locationTextLayer.start + 1);
// highlight-end

audio

Its time for the cherry atop our fancy layer cake, the audio! We can bring music into our video with a simple call to composition.addAudio(). We can trim the audio to the duration of the composition, which was set as the sum total of the durations of our sequenced layers in our call to addSequence. Finally, we can fade out the audio over that 10 second period of blackness at the end of the video by providing a transitions configuration object.

// ...
hostedByTextLayer.setStart(locationTextLayer.start + 1);


// Add this code:
// highlight-start
await composition.addAudio(
  audio,
  { volume: 1 },
  {
    transitions: [
      {
        duration: 10,
        type: "fadeOut",
      },
    ],
    trim: {
      end: composition.duration,
    },
  }
);
// highlight-end

encode

Now that all our layers are added, configured, and sequenced, we can add this last piece of code to send off our composition to the Editframe servers and kick off an encode.

// ...
await composition.addAudio(
  // ...
);


// Add this code:
// highlight-start
const result = await composition.encode({ synchronously: true });


console.log(JSON.stringify(result, null, 2));
// highlight-end

Rendering the video

Execute index.js by either clicking the big green ▶️ Run button, or opening the Shell tab and running node ./index.js.

This video takes roughly 90 seconds to encode. If you don't run into any errors, once encoding is complete, you should see something like this logged to your terminal:

{
  "id": "<video_id>",
  "isFailed": false,
  "isReady": true,
  "downloadUrl": "https://api.editframe.com/v2/videos/<video_id>/download?client_id=<your_client_id>",
  "duration": 44,
  "timestamp": 1654816890,
  "streamUrl": "https://api.editframe.com/v2/videos/<video_id>/stream?client_id=<your_client_id>",
  "thumbnailUrl": "https://api.editframe.com/v2/videos/<video_id>/thumbnail?client_id=<your_client_id>",
  "metadata": {}
}

Congratulations! You've successfully constructed and generated a video using editframe-js!

Click on the streamUrl to watch your video. You can also find your video in the Videos tab in the Editframe dashboard.

Templatizing the video

This may seem obvious, but the final step in our templatization process is to pull out the hard-coded asset urls and text strings and wrap our editframe code into a single function. This function can then be called with different media and text to generate many more videos in this same style but with different content.

import { Editframe } from "@editframe/editframe-js";


// Add this code:
// highlight-start
const buildVideo = async ({
  audio,
  images: { introImage, part1Images, part2Images, logoImage },
  text: { locationText, hostText, catchphraseText, catchphraseText2 },
}) => {
  // Move the existing code to here, except the URL and text variables
};
//highlight-end\n
// Keep variables outside of \`buildVideo\`:
// highlight-start
const audio = "https://editframe.com/docs/guides/airbnb-commercial/arlae.mp3";
const introImage = "https://editframe.com/docs/guides/airbnb-commercial/together.jpg";
const part1Images = [
  "https://editframe.com/docs/guides/airbnb-commercial/ocean-drive.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/juice.jpg",
];
const part2Images = [
  "https://editframe.com/docs/guides/airbnb-commercial/crab.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/street-art.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/tree.jpg",
  "https://editframe.com/docs/guides/airbnb-commercial/sushi.jpg",
];
const spacerImage = "https://editframe.com/docs/guides/airbnb-commercial/spacer.jpg";
const logoImage = "https://editframe.com/docs/guides/airbnb-commercial/editframe-logo.png";
const locationText = "Miami Beach";
const hostText = "Jean Carlos";
const catchphraseText = "Something to write home about";
const catchphraseText2 = "Made possible by Editframe";
// highlight-end


// Add this code:
// highlight-start
buildVideo({
  audio,
  images: { introImage, part1Images, part2Images, logoImage },
  text: { locationText, hostText, catchphraseText, catchphraseText2 },
});
// highlight-end

Conclusion

We hope this has been a fun project for you to play with, and that is has inspired you to dream up ideas for videos of your own! If you encounter any issues while following this guide, please don't hesitate to reach out at team@editframe.com

Full Working Example

If you've been following along in Replit, the full working example is in your repl in a file named final.js. At this point, your index.js should look the same as final.js.

If you haven't been following along, the full working example code can be found at https://replit.com/@editframe/airbnb-made-possible-by-hosts#final.js.

Previous
Videos