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.
- Open the @editframe/airbnb-made-possible-by-hosts repl
- Click Fork repl (you'll be asked to create a Replit account if you don't have one)
- If you haven't already, you'll need to create an Editframe account
- In the repl, click the 🔒 Secrets (Environment variables) tab on the left
- Add the following secrets. You can find the values in the Editframe dashboard:
key | value |
---|---|
EDITFRAME_CLIENT_ID | Client ID |
EDITFRAME_API_TOKEN | Production 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:
- An intro image that fades in.
- A series of images that cut from one to another without a transition.
- A line of text with the name of the location.
- A line of text that fades in below the location shortly after.
- A series of images that fade in and out to black with a moment of black space between.
- Two back-to-back lines of text with a cute catch-phrase.
- A line of text taking credit for making this video possible 😉
- A logo.
- 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.
- We'll need the
composition.addImage()
method for creating theimage
layers. - We'll need the
composiiton.addText()
method for creating thetext
layers. - 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. - 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 inherentduration
, by default, all images take up the full duration of their composition. We can supply a trim configuration object to trim our image layers to3
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.