├── api ├── .env.dist ├── .gitignore ├── src │ ├── handler │ │ ├── domain │ │ │ ├── handler.js │ │ │ └── lib │ │ │ │ └── address.js │ │ └── shotstack │ │ │ ├── handler.js │ │ │ └── lib │ │ │ ├── properties.js │ │ │ ├── shotstack.js │ │ │ └── template.json │ ├── shared │ │ └── response.js │ └── app.js ├── package.json └── serverless.yml ├── LICENSE ├── web ├── styles.css ├── index.html └── app.js └── README.md /api/.env.dist: -------------------------------------------------------------------------------- 1 | DOMAIN_API_KEY=replace_with_your_domain_key 2 | SHOTSTACK_API_KEY=replace_with_your_shotstack_key 3 | SHOTSTACK_HOST=https://api.shotstack.io/stage/ 4 | SERVERLESS_DEPLOYMENT_BUCKET_PREFIX= 5 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Environment variables 9 | .env 10 | 11 | # Other 12 | yarn.lock 13 | package-lock.json 14 | .DS_Store -------------------------------------------------------------------------------- /api/src/handler/domain/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const response = require('../../shared/response'); 4 | const address = require('./lib/address'); 5 | 6 | module.exports.search = (event, context, callback) => { 7 | const search = decodeURIComponent(event.pathParameters.search); 8 | 9 | address.search(search).then((res) => { 10 | console.log('Success'); 11 | callback(null, response.format(200, 'success', 'OK', res)); 12 | }).catch(function(res) { 13 | console.log('Fail: ', res); 14 | callback(null, response.format(400, 'fail', 'Bad Request', res)); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-domain-shotstack", 3 | "version": "1.0.0", 4 | "description": "Shotstack demo using Domain API and Serverless", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@hapi/joi": "^15.0.3", 8 | "dotenv": "^8.2.0", 9 | "request": "^2.88.0" 10 | }, 11 | "devDependencies": { 12 | "body-parser": "^1.19.0", 13 | "express": "^4.17.1", 14 | "nodemon": "^2.0.7" 15 | }, 16 | "scripts": { 17 | "deploy": "sls deploy", 18 | "start": "node src/app.js", 19 | "dev": "nodemon src/app.js" 20 | }, 21 | "author": "Shotstack", 22 | "license": "ISC" 23 | } 24 | -------------------------------------------------------------------------------- /api/src/shared/response.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Format the response body 5 | * 6 | * @param {String} status 7 | * @param {String} message 8 | * @param {Object} data 9 | * 10 | * @returns {{status: *, message: *, data: *}} 11 | */ 12 | module.exports.getBody = (status, message, data) => { 13 | return { 14 | status: status, 15 | message: message, 16 | data: data 17 | } 18 | }; 19 | 20 | /** 21 | * API Standard response format (JSend - https://labs.omniti.com/labs/jsend) 22 | * 23 | * @param {Number} code 24 | * @param {String} status 25 | * @param {String} message 26 | * @param {Object} data 27 | * @returns {{statusCode: *, headers: {Access-Control-Allow-Origin: string}, body}} 28 | */ 29 | module.exports.format = (code, status, message, data) => { 30 | return { 31 | statusCode: parseInt(code), 32 | headers: { 33 | 'Access-Control-Allow-Origin' : '*' 34 | }, 35 | body: JSON.stringify(this.getBody(status, message, data)) 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shotstack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /api/src/handler/shotstack/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const response = require('../../shared/response'); 4 | const shotstack = require('./lib/shotstack'); 5 | const properties = require('./lib/properties'); 6 | 7 | module.exports.submit = async (event, context, callback) => { 8 | const data = JSON.parse(event.body); 9 | let json; 10 | 11 | try { 12 | const property = await properties.get(data.propertyId); 13 | json = await shotstack.createJson(property); 14 | } catch (res) { 15 | console.log('Fail: ', res); 16 | callback(null, response.format(400, 'fail', 'Bad Request', res)); 17 | } 18 | 19 | await shotstack.submit(json).then((res) => { 20 | console.log('Success'); 21 | callback(null, response.format(201, 'success', 'OK', res)); 22 | }).catch(function(res) { 23 | console.log('Fail: ', res); 24 | callback(null, response.format(400, 'fail', 'Bad Request', res)); 25 | }); 26 | }; 27 | 28 | module.exports.status = async (event, context, callback) => { 29 | const id = event.pathParameters.id; 30 | 31 | await shotstack.status(id).then((res) => { 32 | console.log('Success'); 33 | callback(null, response.format(200, 'success', 'OK', res)); 34 | }).catch(function(res) { 35 | console.log('Fail: ', res); 36 | callback(null, response.format(400, 'fail', 'Bad Request', res)); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /api/src/handler/domain/lib/address.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request'); 4 | const Joi = require('@hapi/joi'); 5 | 6 | const domainUrl = 'https://api.domain.com.au/v1/'; 7 | const domainApiKey = process.env.DOMAIN_API_KEY; 8 | 9 | /** 10 | * Domain suggestions based on a human-readable address: 11 | * - https://developer.domain.com.au/docs/v1/apis/pkg_address_suggestion/references/properties_suggest 12 | * 13 | * @params search {string} - human-readable address 14 | * 15 | * @returns {any} - response body from Domain 16 | */ 17 | module.exports.search = (search) => { 18 | const schema = { 19 | search: Joi.string().regex(/^[a-zA-Z0-9 ,-\/]*$/).min(2).max(100).required(), 20 | }; 21 | 22 | const valid = Joi.validate({ 23 | search: search 24 | }, schema); 25 | 26 | return new Promise((resolve, reject) => { 27 | if (valid.error) { 28 | return reject(valid.error); 29 | } 30 | 31 | request({ 32 | url: domainUrl + 'properties/_suggest?terms=' + encodeURIComponent(search), 33 | method: 'GET', 34 | headers: { 35 | 'x-api-key': domainApiKey 36 | }, 37 | json: true 38 | }, function (error, response, body) { 39 | if (error) { 40 | console.log(error); 41 | return reject(error); 42 | } 43 | 44 | return resolve(body); 45 | }); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /api/serverless.yml: -------------------------------------------------------------------------------- 1 | service: demo-domain-shotstack 2 | useDotenv: true 3 | 4 | provider: 5 | name: aws 6 | runtime: nodejs14.x 7 | stage: demo 8 | region: ap-southeast-2 9 | logRetentionInDays: 30 10 | deploymentBucket: 11 | name: shotstack-serverless-deploys-${self:provider.region} 12 | blockPublicAccess: true 13 | environment: 14 | DOMAIN_API_KEY: ${env:DOMAIN_API_KEY} 15 | SHOTSTACK_API_KEY: ${env:SHOTSTACK_API_KEY} 16 | SHOTSTACK_HOST: ${env:SHOTSTACK_HOST} 17 | 18 | package: 19 | exclude: 20 | - .env 21 | - .env.dist 22 | - package.json 23 | - package-lock.json 24 | - src/app.js 25 | - node_modules/aws-sdk/** 26 | - node_modules/**/aws-sdk/** 27 | 28 | functions: 29 | shotstack: 30 | handler: src/handler/shotstack/handler.submit 31 | description: Demo - Domain slideshow render 32 | timeout: 15 33 | memorySize: 128 34 | events: 35 | - http: 36 | path: shotstack 37 | method: post 38 | cors: true 39 | status: 40 | handler: src/handler/shotstack/handler.status 41 | description: Demo - Domain slideshow status check 42 | timeout: 10 43 | memorySize: 128 44 | events: 45 | - http: 46 | path: shotstack/{id} 47 | method: get 48 | cors: true 49 | search: 50 | handler: src/handler/domain/handler.search 51 | description: Demo - Domain property search 52 | timeout: 10 53 | memorySize: 128 54 | events: 55 | - http: 56 | path: domain/search/{search} 57 | method: get 58 | cors: true 59 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/lib/properties.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request'); 4 | const Joi = require('@hapi/joi'); 5 | 6 | const domainUrl = 'https://api.domain.com.au/v1/'; 7 | const domainApiKey = process.env.DOMAIN_API_KEY; 8 | 9 | /** 10 | * Domain property lookup of previously-found id using the search call. 11 | * - https://developer.domain.com.au/docs/v1/apis/pkg_properties_locations/references/properties_get 12 | * 13 | * @params id {string} - property identifier 14 | * 15 | * @returns {any} - response from Domain 16 | */ 17 | module.exports.get = (id) => { 18 | const schema = { 19 | id: Joi.string().regex(/^[a-zA-Z0-9 -]*$/).min(2).max(100).required(), 20 | }; 21 | 22 | const valid = Joi.validate({ 23 | id: id, 24 | }, schema); 25 | 26 | return new Promise((resolve, reject) => { 27 | if (valid.error) { 28 | return reject(valid.error); 29 | } 30 | 31 | request({ 32 | url: domainUrl + 'properties/' + encodeURIComponent(id), 33 | method: 'GET', 34 | headers: { 35 | 'x-api-key': domainApiKey 36 | }, 37 | json: true 38 | }, function (error, response, body) { 39 | if (error) { 40 | console.log(error); 41 | return reject(error); 42 | } 43 | 44 | if (body.message) { 45 | return reject(body.message.replace(/['"]+/g, '')); 46 | } 47 | 48 | if (!body.photos.length) { 49 | return reject('This property has no photos.'); 50 | } 51 | 52 | return resolve(body); 53 | }); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /web/styles.css: -------------------------------------------------------------------------------- 1 | .content .jumbotron { 2 | padding-top: 2rem; 3 | padding-bottom: 2rem; 4 | margin-bottom: 0; 5 | } 6 | .display-4 { 7 | font-size: 2.5rem; 8 | } 9 | 10 | .video-container { 11 | padding-bottom: 16px; 12 | } 13 | 14 | video { 15 | margin-bottom: 0 !important; 16 | } 17 | 18 | #player, #json { 19 | display: none; 20 | } 21 | 22 | #status .fas { 23 | margin-bottom: 8px; 24 | } 25 | 26 | #status small { 27 | margin-top: 10px; 28 | color: #777777; 29 | } 30 | 31 | .progress { 32 | height: 2px; 33 | margin-bottom: 14px; 34 | } 35 | 36 | #json { 37 | margin-top: 16px; 38 | } 39 | .json-key { 40 | color: brown; 41 | } 42 | .json-value { 43 | color: navy; 44 | } 45 | .json-string { 46 | color: olive; 47 | } 48 | 49 | header { 50 | margin-bottom: 2rem; 51 | } 52 | 53 | .btn-primary { 54 | font-weight: bold; 55 | background-color: #25d3d0; 56 | border-color: #25d3d0; 57 | } 58 | 59 | .btn-primary:hover, .btn-primary:active, 60 | .btn-primary.disabled, .btn-primary:disabled { 61 | background-color: #25d3d0; 62 | border-color: #25d3d0; 63 | } 64 | 65 | .btn-primary.disabled, .btn-primary:disabled { 66 | background-color: #dbdbdb; 67 | border-color: #dbdbdb; 68 | } 69 | 70 | a { 71 | color: #25d3d0; 72 | } 73 | a:hover { 74 | color: #25d3d0; 75 | } 76 | 77 | .video-container .jumbotron { 78 | background-color: transparent; 79 | } 80 | 81 | #instructions, #status { 82 | margin-top: 7rem; 83 | } 84 | 85 | #instructions p { 86 | margin: 0; 87 | } 88 | 89 | label { 90 | font-weight: 600; 91 | } 92 | 93 | ul { 94 | list-style-type: none; 95 | padding: 0; 96 | font-size: 0.9rem; 97 | } 98 | -------------------------------------------------------------------------------- /api/src/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const path = require('path'); 6 | const shotstack = require('./handler/shotstack/lib/shotstack'); 7 | const properties = require('./handler/shotstack/lib/properties'); 8 | const address = require('./handler/domain/lib/address'); 9 | const responseHandler = require('./shared/response'); 10 | const app = express(); 11 | 12 | app.use(bodyParser.urlencoded({ extended: false })); 13 | app.use(bodyParser.json()); 14 | app.use(express.static(path.join(__dirname + '../../../web'))); 15 | 16 | app.post('/demo/shotstack', async (req, res) => { 17 | try { 18 | const property = await properties.get(req.body.propertyId); 19 | const json = await shotstack.createJson(property); 20 | const render = await shotstack.submit(json); 21 | 22 | res.header("Access-Control-Allow-Origin", "*"); 23 | res.status(201); 24 | res.send(responseHandler.getBody('success', 'OK', render)); 25 | } catch (err) { 26 | res.header("Access-Control-Allow-Origin", "*"); 27 | res.status(400); 28 | res.send(responseHandler.getBody('fail', 'Bad Request', err)); 29 | } 30 | }); 31 | 32 | app.get('/demo/shotstack/:renderId', async (req, res) => { 33 | try { 34 | const render = await shotstack.status(req.params.renderId); 35 | 36 | res.header("Access-Control-Allow-Origin", "*"); 37 | res.status(200); 38 | res.send(responseHandler.getBody('success', 'OK', render)); 39 | } catch (err) { 40 | res.header("Access-Control-Allow-Origin", "*"); 41 | res.status(400); 42 | res.send(responseHandler.getBody('fail', 'Bad Request', err)); 43 | } 44 | }); 45 | 46 | app.get('/demo/domain/search/:search', async (req, res) => { 47 | try { 48 | const properties = await address.search(req.params.search); 49 | 50 | res.header("Access-Control-Allow-Origin", "*"); 51 | res.status(200); 52 | res.send(responseHandler.getBody('success', 'OK', properties)); 53 | } catch (err) { 54 | res.header("Access-Control-Allow-Origin", "*"); 55 | res.status(400); 56 | res.send(responseHandler.getBody('fail', 'Bad request', err)); 57 | } 58 | }); 59 | 60 | app.listen(3000, () => console.log("Server running...\n\nOpen http://localhost:3000 in your browser\n")); 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shotstack Domain API Demo 2 | 3 | This project demonstrates how to use the Shotstack cloud [video editing API](https://shotstack.io) to create 4 | a video using property information sourced from the [Domain group](https://developer.domain.com.au/). 5 | 6 | An HTML web form allows the user to search for a property using the Domain 7 | Group's property API. Address and property information is added automatically, 8 | and Luma matte transitions are used to create more engaging video effects. The 9 | final video is created by the Shotstack API using images returned by the Domain 10 | search. 11 | 12 | View the live demo at: https://shotstack.io/demo/domain-real-estate-api/ 13 | 14 | The demo is built using Node JS and can be used with either Express Framework or deployed 15 | as a serverless projects using AWS Lambda and API Gateway. 16 | 17 | ### Requirements 18 | 19 | - Node 8.10+ 20 | - Domain developer API key: https://developer.domain.com.au 21 | - Shotstack API key: https://dashboard.shotstack.io/register 22 | 23 | ### Project Structure 24 | 25 | The project is divided in to a two components: 26 | 27 | #### Backend API 28 | 29 | The backend API with an endpoint which searches the Domain API, prepares the edit and posts 30 | the data to the Shotstack API. A status endpoint is also available which can be polled to 31 | return the status of the video as it renders. 32 | 33 | The backend API source code is in the _api_ directory. 34 | 35 | #### Frontend Web Form & Player 36 | 37 | The frontend is a simple HTML form that allows the user to enter a search term and basic 38 | options to create a video. The form uses jQuery to submit the data to the backend API and 39 | poll the status of the current render. There is also a video player that is loaded with 40 | the final rendered video when ready. 41 | 42 | The front end API source code is in the _web_ directory. 43 | 44 | ### Installation 45 | 46 | Install node module dependencies: 47 | 48 | ```bash 49 | cd api 50 | npm install 51 | ``` 52 | 53 | ### Configuration 54 | 55 | Copy the .env.dist file and rename it .env: 56 | 57 | ``` 58 | cp .env.dist .env 59 | ``` 60 | 61 | Replace the environment variables below with your 62 | Domain and Shotstack API key (staging key): 63 | 64 | ```bash 65 | DOMAIN_API_KEY=replace_with_your_domain_key 66 | SHOTSTACK_API_KEY=replace_with_your_shotstack_key 67 | ``` 68 | 69 | ### Run Locally 70 | 71 | To start the API and serve the front end form (from the _api_ directory): 72 | 73 | ```bash 74 | npm run start 75 | ``` 76 | 77 | The visit [http://localhost:3000](http://localhost:3000) 78 | 79 | 80 | ### Deploy Serverless Application (optional) 81 | 82 | The project has been built as a serverless application using the Serverless Framework 83 | and AWS Lambda. To understand more about the Serverless Framework and how to set 84 | everything up consult the documentation: https://serverless.com/framework/docs/providers/aws/ 85 | 86 | To deploy to AWS Lambda (from the _api_ directory): 87 | 88 | ```bash 89 | cd api 90 | npm run serverless 91 | ``` 92 | 93 | Once the API is deployed set the `var apiEndpoint` variable in **web/app.js** to the returned 94 | API Gateway URL. 95 | 96 | Run the **web/index.html** file locally or use AWS S3 static hosting to serve the web page. 97 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Shotstack Domain.com.au Video Demo 17 | 18 | 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 39 | Search for an Australian street name, city, state or suburb. 40 |
41 |
42 |
43 | 44 |
    45 |
    46 |
    47 | 48 |
    49 |
    50 | 56 |
    57 |
    58 | 59 | Powered by Domain 65 | 66 |
    67 |
    68 |
    69 |
    70 |
    71 | 72 | Get the Source Code 73 |
    74 |
    75 |
    76 |
    77 |
    78 |

    Your video will display here

    79 |
    80 |
    81 |
    82 |
    83 | 84 |

    85 |
    86 |
    87 |
    88 | Hold tight, rendering may take a minute... 89 |
    90 |
    91 | 92 |
    93 |
    94 |

    95 | 98 |

    99 |
    100 |
    101 |
    102 |
    103 |
    104 |
    105 |
    106 |
    107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/lib/shotstack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request'); 4 | const Joi = require('@hapi/joi'); 5 | const fs = require('fs'); 6 | 7 | const shotstackUrl = process.env.SHOTSTACK_HOST; 8 | const shotstackApiKey = process.env.SHOTSTACK_API_KEY; 9 | 10 | const TRACK_PROPERTY = 0; 11 | const TRACK_FEATURES = 1; 12 | const PHOTO_FIXATION_DURATION = 6; // seconds 13 | 14 | const validateBody = (body) => { 15 | const schema = { 16 | property: Joi.object({ 17 | streetAddress: Joi.string().required(), 18 | suburb: Joi.string().required(), 19 | state: Joi.string().required(), 20 | postcode: Joi.string().required(), 21 | photos: Joi.array().items({ 22 | fullUrl: Joi.string().required(), 23 | }).min(1).required(), 24 | }), 25 | }; 26 | 27 | const valid = Joi.validate({ 28 | property: body.property, 29 | }, schema, { 30 | allowUnknown: true, 31 | }); 32 | 33 | return valid; 34 | }; 35 | 36 | /** 37 | * Update the JSON for the video, based on what the Domain API returned. 38 | * 39 | * @param body {any} - the data from Domain 40 | * 41 | * @returns {Promise} - a promise that resolves to the JSON for the video 42 | */ 43 | module.exports.createJson = (body) => { 44 | /** 45 | * Clean the response from Domain to satisfy Shotstack template requirements. 46 | * 47 | * The photos fields returned from Domain includes fullUrl, a path to a 48 | * photograph. Some properties use a link to a video instead (eg: 49 | * ES-8535-JD). We can't submit video as an image in our template for 50 | * rendering, so remove anything that does not come from Domain's image 51 | * buckets. See: 52 | * - https://developer.domain.com.au/docs/v1/conventions/image-resizing 53 | * 54 | * @param body {any} - full JSON response from Domain 55 | * 56 | * @returns {any} - cleaned JSON response 57 | */ 58 | const processDomainResponse = (property) => { 59 | const re = new RegExp(/https:\/\/bucket-api.(domain|commercialrealestate).com.au\/v1/); 60 | 61 | property.photos = property.photos 62 | .filter((photo) => re.test(photo.fullUrl)); 63 | 64 | return property; 65 | }; 66 | 67 | /** 68 | * Some properties do not return any bedrooms, bathrooms or carSpaces. 69 | * Remove these from the features track in the timeline if they are not 70 | * found in the JSON. If all are removed, remove the track as well. 71 | * 72 | * @params jsonParsed {any} - the JSON object we are working with 73 | * @params body {any} - the property object from Domain 74 | */ 75 | const updateFeaturesTrack = (jsonParsed, property) => { 76 | /* 77 | * Remove features from the bottom-most clip up to the top. This preserves 78 | * index ordering for subsequent splice()ing. 79 | */ 80 | if (property.carSpaces === '') { 81 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(5, 1); 82 | } else { 83 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips[5].asset.html = `

    ${property.carSpaces}

    `; 84 | } 85 | if (property.bathrooms === '') { 86 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(4, 1); 87 | } else { 88 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips[4].asset.html = `

    ${property.bathrooms}

    `; 89 | } 90 | if (property.bedrooms === '') { 91 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(3, 1); 92 | } else { 93 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips[3].asset.html = `

    ${property.bedrooms}

    `; 94 | } 95 | 96 | /* 97 | * Remove the icons if there are no values for the corresponding feature. 98 | */ 99 | if (property.carSpaces === '') { 100 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(2, 1); 101 | } 102 | if (property.bathrooms === '') { 103 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(1, 1); 104 | } 105 | if (property.bedrooms === '') { 106 | jsonParsed.timeline.tracks[TRACK_FEATURES].clips.splice(0, 1); 107 | } 108 | 109 | /* 110 | * Clear the entire track if there are no clips. 111 | */ 112 | if (jsonParsed.timeline.tracks[TRACK_FEATURES].clips.length === 0) { 113 | jsonParsed.timeline.tracks.splice(TRACK_FEATURES, 1); 114 | } 115 | }; 116 | 117 | /** 118 | * Update the photos. The list of photos may be fewer than what is in the 119 | * template, so it may be necessary to remove unused clips between the 120 | * intro clip and the outro clip, as well as adjust track timing. 121 | * 122 | * @params jsonParsed {any} - the JSON object we are working with 123 | * @params property {any} - the property object from Domain 124 | */ 125 | const updatePhotos = (jsonParsed, property) => { 126 | // Recalculate track indexes in case the feature track was removed. 127 | const trackPhotoIndex = jsonParsed.timeline.tracks.length - 1; 128 | const trackTransitionIndex = trackPhotoIndex - 1; 129 | const trackAgencyLogoIndex = trackTransitionIndex - 1; 130 | const trackAgencyTextIndex = trackAgencyLogoIndex - 1; 131 | const trackAgencyPhotoIndex = trackAgencyTextIndex - 1; 132 | 133 | const numTemplatePhotos = jsonParsed.timeline.tracks[trackPhotoIndex].clips.length; 134 | const numPhotos = Math.min(property.photos.length, jsonParsed.timeline.tracks[trackPhotoIndex].clips.length-1); 135 | const numRemove = numTemplatePhotos - numPhotos - 1; 136 | 137 | const removeStart = numTemplatePhotos - numRemove - 1; 138 | const outroStart = PHOTO_FIXATION_DURATION * numPhotos; 139 | 140 | // Remove unnecessary clips from video and transitions tracks. 141 | jsonParsed.timeline.tracks[trackTransitionIndex].clips.splice(removeStart, numRemove); 142 | jsonParsed.timeline.tracks[trackPhotoIndex].clips.splice(removeStart, numRemove); 143 | 144 | const lastClipIndex = jsonParsed.timeline.tracks[trackPhotoIndex].clips.length - 1; 145 | 146 | // Update last clip length for photos. 147 | jsonParsed.timeline.tracks[trackPhotoIndex].clips[lastClipIndex].start = outroStart; 148 | 149 | // Update last transition start to one second before last photo. 150 | jsonParsed.timeline.tracks[trackTransitionIndex].clips[lastClipIndex].start = outroStart - 1; 151 | 152 | // Update the agency tracks. 153 | jsonParsed.timeline.tracks[trackAgencyPhotoIndex].clips[0].start = outroStart; 154 | jsonParsed.timeline.tracks[trackAgencyPhotoIndex].clips[1].start = outroStart; 155 | jsonParsed.timeline.tracks[trackAgencyTextIndex].clips[0].start = outroStart; 156 | jsonParsed.timeline.tracks[trackAgencyTextIndex].clips[1].start = outroStart; 157 | jsonParsed.timeline.tracks[trackAgencyLogoIndex].clips[0].start = outroStart; 158 | 159 | // Overwrite the photos in the template with those from Domain. 160 | let i; 161 | for (i = 0; i < numPhotos; i++) { 162 | jsonParsed.timeline.tracks[trackPhotoIndex].clips[i].asset.src = property.photos[i].fullUrl; 163 | } 164 | 165 | // Set outro photo to be the same as the intro photo. 166 | jsonParsed.timeline.tracks[trackPhotoIndex].clips[lastClipIndex].asset.src = property.photos[0].fullUrl; 167 | }; 168 | 169 | /** 170 | * Update the property information. 171 | * 172 | * @params jsonParsed {any} - the JSON object we are working with 173 | * @params property {any} - the property object from Domain 174 | */ 175 | const updatePropertyTrack = (jsonParsed, property) => { 176 | const unit = property.flatNumber ? (property.flatNumber !== '' ? `${property.flatNumber}/` : '') : ''; 177 | const address = (unit + property.streetAddress).toUpperCase(); 178 | const category = property.propertyCategory ? property.propertyCategory.toUpperCase() : ''; 179 | 180 | jsonParsed.timeline.tracks[TRACK_PROPERTY].clips[0].asset.html = `

    ${address}

    `; 181 | jsonParsed.timeline.tracks[TRACK_PROPERTY].clips[1].asset.html = 182 | `

    ${property.suburb.toUpperCase()}, ${property.state.toUpperCase()} ${property.postcode}

    `; 183 | 184 | jsonParsed.timeline.tracks[TRACK_PROPERTY].clips[3].asset.html = `

    ${category}

    `; 185 | }; 186 | 187 | return new Promise((resolve, reject) => { 188 | const cleanedBody = processDomainResponse(body); 189 | const valid = validateBody(cleanedBody); 190 | 191 | if (valid.error) { 192 | return reject(valid.error); 193 | } 194 | 195 | fs.readFile(__dirname + '/template.json', 'utf-8', function (err, data) { 196 | if (err) { 197 | console.error(err); 198 | return reject(err); 199 | } 200 | 201 | const jsonParsed = JSON.parse(data); 202 | 203 | updatePropertyTrack(jsonParsed, cleanedBody); 204 | updateFeaturesTrack(jsonParsed, cleanedBody); 205 | updatePhotos(jsonParsed, cleanedBody); 206 | 207 | return resolve(jsonParsed); 208 | }); 209 | }); 210 | }; 211 | 212 | module.exports.submit = (data) => { 213 | return new Promise((resolve, reject) => { 214 | request({ 215 | url: shotstackUrl + 'render', 216 | method: 'POST', 217 | headers: { 218 | 'x-api-key': shotstackApiKey 219 | }, 220 | json: true, 221 | body: data 222 | }, function (error, response, body){ 223 | if (error) { 224 | console.log(error); 225 | return reject(error); 226 | } 227 | 228 | return resolve(body.response); 229 | }); 230 | }); 231 | }; 232 | 233 | module.exports.status = (id) => { 234 | const schema = { 235 | id: Joi.string().guid({ 236 | version: [ 237 | 'uuidv4', 238 | 'uuidv5' 239 | ] 240 | }) 241 | }; 242 | 243 | const valid = Joi.validate({ 244 | id: id 245 | }, schema); 246 | 247 | return new Promise((resolve, reject) => { 248 | if (valid.error) { 249 | return reject(valid.error); 250 | } 251 | 252 | request({ 253 | url: shotstackUrl + 'render/' + id, 254 | method: 'GET', 255 | headers: { 256 | 'x-api-key': shotstackApiKey 257 | }, 258 | json: true 259 | }, function (error, response, body) { 260 | if (error) { 261 | console.log(error); 262 | return reject(error); 263 | } 264 | 265 | return resolve(body.response); 266 | }); 267 | }); 268 | }; 269 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | var apiUrl = 'http://localhost:3000/demo/'; // 'https://dsgyryplcg.execute-api.ap-southeast-2.amazonaws.com/demo/'; 2 | var apiEndpoint = apiUrl + 'shotstack'; 3 | var domainEndpoint = apiUrl + 'domain'; 4 | var progress = 0; 5 | var progressIncrement = 10; 6 | var pollIntervalSeconds = 10; 7 | var unknownError = 'An unknown error has occurred.'; 8 | var player; 9 | 10 | var maxProperties = 5; 11 | var niceProperties = [ 12 | '6 Lucania Court, Tamborine Mountain', 13 | '14 Bennets Ash Road, Noosa Heads', 14 | '192 Ocean Parade, Burleigh Heads', 15 | 16 | '19 Taylor Street, Darlinghurst', 17 | '8 Roosevelt Avenue, Allambie Heights', 18 | '24 Philip Road, Mona Vale', 19 | 20 | '368 Cardigan Street, Carlton', 21 | '31 Pardalote Rise, Red Hill', 22 | '10 Erica Street, Windsor', 23 | 24 | '11 Hood Street, Linden Park', 25 | '95 Kingfisher Circuit, Flagstaff Hill', 26 | '1 Piccadilly Road, Crafers', 27 | 28 | '23 Brookman Street, Perth', 29 | '25 Beatrice Road, Dalkeith', 30 | '28 Templetonia Crescent, City Beach', 31 | 32 | '38 Coolabah Road, Sandy Bay', 33 | '8 Tiersen Place, Sandy Bay', 34 | '121 Emmett Street, Smithton', 35 | ]; 36 | 37 | var keyUpTimer; 38 | var keySearchTimeout = 250; // ms 39 | var chosenPropertyId = ''; 40 | 41 | /** 42 | * Initialise and play the video 43 | * 44 | * @param {String} src the video URL 45 | */ 46 | function initialiseVideo(src) { 47 | player = new Plyr('#player'); 48 | 49 | player.source = { 50 | type: 'video', 51 | sources: [{ 52 | src: src, 53 | type: 'video/mp4', 54 | }] 55 | }; 56 | 57 | $('#status').removeClass('d-flex').addClass('d-none'); 58 | $('#player').show(); 59 | 60 | player.play(); 61 | } 62 | 63 | /** 64 | * Check the render status of the video 65 | * 66 | * @param {String} id the render job UUID 67 | */ 68 | function pollVideoStatus(id) { 69 | $.get({ 70 | url: apiEndpoint + '/' + id, 71 | dataType: 'json', 72 | }, function(response) { 73 | updateStatus(response.data.status); 74 | if (!(response.data.status === 'done' || response.data.status === 'failed')) { 75 | setTimeout(function () { 76 | pollVideoStatus(id); 77 | }, pollIntervalSeconds * 1000); 78 | } else if (response.data.status === 'failed') { 79 | updateStatus(response.data.status); 80 | } else { 81 | initialiseVideo(response.data.url); 82 | initialiseJson(response.data.data); 83 | } 84 | }); 85 | } 86 | 87 | /** 88 | * Update status message and progress bar 89 | * 90 | * @param {String} status the status text 91 | */ 92 | function updateStatus(status) { 93 | if (progress <= 90) { 94 | progress += progressIncrement; 95 | } 96 | 97 | if (status === 'submitted') { 98 | $('#status .fas').attr('class', 'fas fa-spinner fa-spin fa-2x'); 99 | $('#status p').text('SUBMITTED'); 100 | } else if (status === 'queued') { 101 | $('#status .fas').attr('class', 'fas fa-history fa-2x'); 102 | $('#status p').text('QUEUED'); 103 | } else if (status === 'fetching') { 104 | $('#status .fas').attr('class', 'fas fa-cloud-download-alt fa-2x'); 105 | $('#status p').text('DOWNLOADING ASSETS'); 106 | } else if (status === 'rendering') { 107 | $('#status .fas').attr('class', 'fas fa-server fa-2x'); 108 | $('#status p').text('RENDERING VIDEO'); 109 | } else if (status === 'saving') { 110 | $('#status .fas').attr('class', 'fas fa-save fa-2x'); 111 | $('#status p').text('SAVING VIDEO'); 112 | } else if (status === 'done') { 113 | $('#status .fas').attr('class', 'fas fa-check-circle fa-2x'); 114 | $('#status p').text('READY'); 115 | progress = 100; 116 | } else { 117 | $('#status .fas').attr('class', 'fas fa-exclamation-triangle fa-2x'); 118 | $('#status p').text('SOMETHING WENT WRONG'); 119 | $('#submit-video').prop('disabled', false); 120 | progress = 0; 121 | } 122 | 123 | $('.progress-bar').css('width', progress + '%').attr('aria-valuenow', progress); 124 | } 125 | 126 | /** 127 | * Display form field and general errors returned by API 128 | * 129 | * @param error 130 | */ 131 | function displayError(error) { 132 | updateStatus(null); 133 | 134 | if (error.status === 400) { 135 | var response = error.responseJSON; 136 | 137 | if (response.data.isJoi) { 138 | $.each(response.data.details, function(index, error) { 139 | if (error.context.key === 'id') { 140 | $('.search-group, #search').addClass('text-danger is-invalid'); 141 | $('.search-group').append('
    The property search is invalid
    ').show(); 142 | } 143 | }); 144 | } else if (typeof response.data === 'string') { 145 | $('#errors').text(response.data).removeClass('d-hide').addClass('d-block'); 146 | } else { 147 | $('#errors').text(unknownError).removeClass('d-hide').addClass('d-block'); 148 | } 149 | } else if (typeof error.message === 'string') { 150 | $('#errors').text(error.message).removeClass('d-hide').addClass('d-block'); 151 | } else { 152 | $('#errors').text(unknownError).removeClass('d-hide').addClass('d-block'); 153 | } 154 | } 155 | 156 | /** 157 | * Reset errors 158 | */ 159 | function resetErrors() { 160 | $('input, label, select').removeClass('text-danger is-invalid'); 161 | $('.invalid-feedback').remove(); 162 | $('#errors').text('').removeClass('d-block').addClass('d-hide'); 163 | } 164 | 165 | /** 166 | * Reset form 167 | */ 168 | function resetForm() { 169 | $('form').trigger("reset"); 170 | $('#submit-video').prop('disabled', false); 171 | $('#property') 172 | .find('option') 173 | .remove().end() 174 | .prop('disabled', true); 175 | } 176 | 177 | /** 178 | * Reset and delete video 179 | */ 180 | function resetVideo() { 181 | if (player) { 182 | player.destroy(); 183 | player = undefined; 184 | } 185 | 186 | progress = 0; 187 | 188 | $('.json-container').html(''); 189 | $('#json').hide(); 190 | } 191 | 192 | /** 193 | * Submit the form with data to create a Shotstack edit 194 | */ 195 | async function submitVideoEdit() { 196 | $('#submit-video').prop('disabled', true); 197 | 198 | try { 199 | $('#instructions').hide(); 200 | $('#status').removeClass('d-none').addClass('d-flex'); 201 | updateStatus('submitted'); 202 | 203 | var formData = { 204 | 'propertyId': chosenPropertyId, 205 | }; 206 | 207 | $.ajax({ 208 | type: 'POST', 209 | url: apiEndpoint, 210 | data: JSON.stringify(formData), 211 | dataType: 'json', 212 | crossDomain: true, 213 | contentType: 'application/json' 214 | }).done(function(response) { 215 | if (response.status !== 'success') { 216 | displayError(response.message); 217 | $('#submit-video').prop('disabled', false); 218 | } else { 219 | pollVideoStatus(response.data.id); 220 | } 221 | }).fail(function(error) { 222 | displayError(error); 223 | $('#submit-video').prop('disabled', false); 224 | }); 225 | } catch (err) { 226 | displayError(err); 227 | } 228 | } 229 | 230 | /** 231 | * Ask Domain to look up a property, and return the results to autocomplete to 232 | * render a dropdown list. 233 | * 234 | * @param query {string} - query to pass to Domain. 235 | * @param callback {function} - autocomplete render function 236 | */ 237 | function submitPropertySearch(query, callback) { 238 | $.get({ 239 | url: domainEndpoint + '/search/' + encodeURIComponent(query), 240 | dataType: 'json', 241 | }, function(response) { 242 | if (response.status !== 'success') { 243 | displayError(response.message); 244 | } else { 245 | var properties = response.data; 246 | 247 | var maxPropertiesToShow = Math.min(properties.length, maxProperties); 248 | properties = properties.slice(0, maxPropertiesToShow); 249 | 250 | callback(properties.map((property) => ({ 251 | id: property.id, 252 | text: property.address 253 | }))); 254 | } 255 | }).fail(function(error) { 256 | displayError(error); 257 | }); 258 | } 259 | 260 | /** 261 | * Colour and style JSON 262 | * 263 | * @param match 264 | * @param pIndent 265 | * @param pKey 266 | * @param pVal 267 | * @param pEnd 268 | * @returns {*} 269 | */ 270 | function styleJson(match, pIndent, pKey, pVal, pEnd) { 271 | var key = '"'; 272 | var val = ''; 273 | var str = ''; 274 | var r = pIndent || ''; 275 | if (pKey) 276 | r = r + key + pKey.replace(/[": ]/g, '') + '": '; 277 | if (pVal) 278 | r = r + (pVal[0] == '"' ? str : val) + pVal + ''; 279 | return r + (pEnd || ''); 280 | } 281 | 282 | /** 283 | * Pretty print JSON object on screen 284 | * 285 | * @param obj 286 | * @returns {string} 287 | */ 288 | function prettyPrintJson(obj) { 289 | var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg; 290 | return JSON.stringify(obj, null, 3) 291 | .replace(/&/g, '&').replace(/\\"/g, '"') 292 | .replace(//g, '>') 293 | .replace(jsonLine, styleJson); 294 | } 295 | 296 | /** 297 | * Show the JSON display button 298 | * 299 | * @param json 300 | */ 301 | function initialiseJson(json) { 302 | $('.json-container').html(prettyPrintJson(json)); 303 | $('#json').show(); 304 | } 305 | 306 | $(document).ready(function() { 307 | $('#recent-properties').append( 308 | niceProperties 309 | .map((address) => ({ address, index: Math.random() })) 310 | .sort((a, b) => a.index - b.index) 311 | .map((property) => `
  • ${property.address}
  • `) 312 | .slice(0, maxProperties) 313 | ); 314 | 315 | $('#recent-properties').on('click', 'a', function(event) { 316 | resetErrors(); 317 | $('#search').val(event.target.text); 318 | $('#search').autoComplete('show'); 319 | }); 320 | 321 | $('#search').autoComplete({ 322 | preventEnter: true, 323 | resolver: 'custom', 324 | events: { 325 | search: function (query, callback) { 326 | $('#submit-video').prop('disabled', true); 327 | if (keyUpTimer) { 328 | clearTimeout(keyUpTimer); 329 | } 330 | 331 | keyUpTimer = setTimeout(function () { 332 | submitPropertySearch(query, callback); 333 | }, keySearchTimeout); 334 | }, 335 | }, 336 | }); 337 | 338 | $('#search').on('autocomplete.select', function (event, item) { 339 | resetErrors(); 340 | $('#submit-video').prop('disabled', false); 341 | chosenPropertyId = item.id; 342 | }); 343 | 344 | $('#search').change(function(event) { 345 | resetErrors(); 346 | }); 347 | 348 | $('#submit-search-form').submit(function(event) { 349 | resetErrors(); 350 | resetVideo(); 351 | submitVideoEdit(); 352 | 353 | event.preventDefault(); 354 | }); 355 | }); 356 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/lib/template.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeline": { 3 | "tracks": [ 4 | { 5 | "clips": [ 6 | { 7 | "asset": { 8 | "type": "html", 9 | "html": "

    192 STOREY STREET

    ", 10 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #f0c20c; font-size: 40px; text-align: left; line-height: 78; }", 11 | "width": 320, 12 | "height": 200, 13 | "position": "bottom" 14 | }, 15 | "start": 1.2, 16 | "length": 4.2, 17 | "position": "left", 18 | "offset": { 19 | "x": 0.05, 20 | "y": 0.3 21 | }, 22 | "transition": { 23 | "in": "slideRight", 24 | "out": "slideLeft" 25 | } 26 | }, 27 | { 28 | "asset": { 29 | "type": "html", 30 | "html": "

    MAROUBRA, NSW 2035

    ", 31 | "css": "p { font-family: \"Manrope Light\"; color: #ffffff; font-size: 22px; text-align: left; line-height: 78; }", 32 | "width": 320, 33 | "height": 66, 34 | "position": "top" 35 | }, 36 | "start": 1.3, 37 | "length": 3.9, 38 | "position": "left", 39 | "offset": { 40 | "x": 0.05, 41 | "y": 0.065 42 | }, 43 | "transition": { 44 | "in": "slideRight", 45 | "out": "slideLeft" 46 | } 47 | }, 48 | { 49 | "asset": { 50 | "type": "html", 51 | "html": "

    AUCTION

    ", 52 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #f0c20c; font-size: 22px; text-align: left; line-height: 78; }", 53 | "width": 320, 54 | "height": 100, 55 | "position": "top" 56 | }, 57 | "start": 1.4, 58 | "length": 4, 59 | "position": "left", 60 | "offset": { 61 | "x": 0.05, 62 | "y": -0.24 63 | }, 64 | "transition": { 65 | "in": "slideRight", 66 | "out": "slideLeft" 67 | } 68 | }, 69 | { 70 | "asset": { 71 | "type": "html", 72 | "html": "

    HOUSE

    ", 73 | "css": "p { font-family: \"Manrope Light\"; color: #ffffff; font-size: 17px; text-align: left; line-height: 78; }", 74 | "width": 320, 75 | "height": 32, 76 | "position": "center" 77 | }, 78 | "start": 1.4, 79 | "length": 4, 80 | "position": "left", 81 | "offset": { 82 | "x": 0.05, 83 | "y": -0.08 84 | }, 85 | "transition": { 86 | "in": "fade", 87 | "out": "fade" 88 | } 89 | } 90 | ] 91 | }, 92 | { 93 | "clips": [ 94 | { 95 | "asset": { 96 | "type": "image", 97 | "src": "https://templates.shotstack.io/basic/asset/image/icon/slimline/white/26px/bed.png" 98 | }, 99 | "start": 1.4, 100 | "length": 4, 101 | "fit": "none", 102 | "position": "left", 103 | "offset": { 104 | "x": 0.055, 105 | "y": -0.025 106 | }, 107 | "transition": { 108 | "in": "fade", 109 | "out": "fade" 110 | } 111 | }, 112 | { 113 | "asset": { 114 | "type": "image", 115 | "src": "https://templates.shotstack.io/basic/asset/image/icon/slimline/white/26px/bath.png" 116 | }, 117 | "start": 1.4, 118 | "length": 4, 119 | "fit": "none", 120 | "position": "left", 121 | "offset": { 122 | "x": 0.13, 123 | "y": -0.025 124 | }, 125 | "transition": { 126 | "in": "fade", 127 | "out": "fade" 128 | } 129 | }, 130 | { 131 | "asset": { 132 | "type": "image", 133 | "src": "https://templates.shotstack.io/basic/asset/image/icon/slimline/white/26px/car.png" 134 | }, 135 | "start": 1.4, 136 | "length": 4, 137 | "fit": "none", 138 | "position": "left", 139 | "offset": { 140 | "x": 0.205, 141 | "y": -0.025 142 | }, 143 | "transition": { 144 | "in": "fade", 145 | "out": "fade" 146 | } 147 | }, 148 | { 149 | "asset": { 150 | "type": "html", 151 | "html": "

    4

    ", 152 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #ffffff; font-size: 18px; text-align: left; }", 153 | "width": 36, 154 | "height": 26, 155 | "position": "center" 156 | }, 157 | "start": 1.4, 158 | "length": 4, 159 | "position": "left", 160 | "offset": { 161 | "x": 0.09, 162 | "y": -0.025 163 | }, 164 | "transition": { 165 | "in": "fade", 166 | "out": "fade" 167 | } 168 | }, 169 | { 170 | "asset": { 171 | "type": "html", 172 | "html": "

    2

    ", 173 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #ffffff; font-size: 18px; text-align: left; }", 174 | "width": 36, 175 | "height": 26, 176 | "position": "center" 177 | }, 178 | "start": 1.4, 179 | "length": 4, 180 | "position": "left", 181 | "offset": { 182 | "x": 0.165, 183 | "y": -0.025 184 | }, 185 | "transition": { 186 | "in": "fade", 187 | "out": "fade" 188 | } 189 | }, 190 | { 191 | "asset": { 192 | "type": "html", 193 | "html": "

    1

    ", 194 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #ffffff; font-size: 18px; text-align: left; }", 195 | "width": 36, 196 | "height": 26, 197 | "position": "center" 198 | }, 199 | "start": 1.4, 200 | "length": 4, 201 | "position": "left", 202 | "offset": { 203 | "x": 0.24, 204 | "y": -0.025 205 | }, 206 | "transition": { 207 | "in": "fade", 208 | "out": "fade" 209 | } 210 | } 211 | ] 212 | }, 213 | { 214 | "clips": [ 215 | { 216 | "asset": { 217 | "type": "luma", 218 | "src": "https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/luma-mattes/circle.jpg" 219 | }, 220 | "start": 30, 221 | "length": 6 222 | }, 223 | { 224 | "asset": { 225 | "type": "image", 226 | "src": "https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/images/real-estate-agent-male.jpg" 227 | }, 228 | "start": 30, 229 | "length": 6, 230 | "fit": "none", 231 | "scale": 0.4, 232 | "offset": { 233 | "x": 0, 234 | "y": 0.22 235 | }, 236 | "transition": { 237 | "in": "fade" 238 | } 239 | } 240 | ] 241 | }, 242 | { 243 | "clips": [ 244 | { 245 | "asset": { 246 | "type": "html", 247 | "html": "

    JEREMY SIMPSON

    ", 248 | "css": "p { font-family: \"Manrope ExtraBold\"; color: #f0c20c; font-size: 26px; text-align: center; }", 249 | "width": 600, 250 | "height": 36, 251 | "position": "center" 252 | }, 253 | "start": 30, 254 | "length": 6, 255 | "offset": { 256 | "x": 0, 257 | "y": 0.045 258 | }, 259 | "transition": { 260 | "in": "fade" 261 | } 262 | }, 263 | { 264 | "asset": { 265 | "type": "html", 266 | "html": "

    0424 998 776
    jeremy@blockrealestate.co

    ", 267 | "css": "p { font-family: \"Manrope Light\"; color: #ffffff; font-size: 18px; text-align: center; }", 268 | "width": 600, 269 | "height": 64, 270 | "position": "center" 271 | }, 272 | "start": 30, 273 | "length": 6, 274 | "offset": { 275 | "x": 0, 276 | "y": -0.24 277 | }, 278 | "transition": { 279 | "in": "fade" 280 | } 281 | } 282 | ] 283 | }, 284 | { 285 | "clips": [ 286 | { 287 | "asset": { 288 | "type": "image", 289 | "src": "https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/logos/real-estate-white.png" 290 | }, 291 | "start": 30, 292 | "length": 6, 293 | "fit": "none", 294 | "scale": 0.26, 295 | "offset": { 296 | "x": 0, 297 | "y": -0.08 298 | }, 299 | "transition": { 300 | "in": "fade" 301 | } 302 | } 303 | ] 304 | }, 305 | { 306 | "clips": [ 307 | { 308 | "asset": { 309 | "type": "video", 310 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/content-left-in.mov" 311 | }, 312 | "start": 0, 313 | "length": 4.48 314 | }, 315 | { 316 | "asset": { 317 | "type": "video", 318 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/content-left-out.mov" 319 | }, 320 | "start": 4.52, 321 | "length": 2 322 | }, 323 | { 324 | "asset": { 325 | "type": "video", 326 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/transition-right.mov" 327 | }, 328 | "start": 10.56, 329 | "length": 3 330 | }, 331 | { 332 | "asset": { 333 | "type": "video", 334 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/transition-up.mov" 335 | }, 336 | "start": 16.56, 337 | "length": 3 338 | }, 339 | { 340 | "asset": { 341 | "type": "video", 342 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/transition-left.mov" 343 | }, 344 | "start": 22.56, 345 | "length": 3 346 | }, 347 | { 348 | "asset": { 349 | "type": "video", 350 | "src": "https://templates.shotstack.io/basic/asset/video/overlay/arrow-sharp/black/outro-in.mov" 351 | }, 352 | "start": 29, 353 | "length": 7 354 | } 355 | ] 356 | }, 357 | { 358 | "clips": [ 359 | { 360 | "asset": { 361 | "type": "image", 362 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate1.jpg" 363 | }, 364 | "start": 0, 365 | "length": 6, 366 | "effect": "zoomInSlow", 367 | "transition": { 368 | "in": "fade" 369 | } 370 | }, 371 | { 372 | "asset": { 373 | "type": "image", 374 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate2.jpg" 375 | }, 376 | "start": 6, 377 | "length": 6, 378 | "effect": "slideLeftSlow" 379 | }, 380 | { 381 | "asset": { 382 | "type": "image", 383 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate3.jpg" 384 | }, 385 | "start": 12, 386 | "length": 6, 387 | "effect": "slideRightSlow" 388 | }, 389 | { 390 | "asset": { 391 | "type": "image", 392 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate4.jpg" 393 | }, 394 | "start": 18, 395 | "length": 6, 396 | "effect": "slideUpSlow" 397 | }, 398 | { 399 | "asset": { 400 | "type": "image", 401 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate5.jpg" 402 | }, 403 | "start": 24, 404 | "length": 6, 405 | "effect": "slideLeftSlow" 406 | }, 407 | { 408 | "asset": { 409 | "type": "image", 410 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/images/realestate1.jpg" 411 | }, 412 | "start": 30, 413 | "length": 6, 414 | "effect": "zoomInSlow" 415 | } 416 | ] 417 | } 418 | ], 419 | "fonts": [ 420 | { 421 | "src": "https://templates.shotstack.io/basic/asset/font/manrope-extrabold.ttf" 422 | }, 423 | { 424 | "src": "https://templates.shotstack.io/basic/asset/font/manrope-light.ttf" 425 | } 426 | ], 427 | "soundtrack": { 428 | "src": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/music/unminus/ambisax.mp3", 429 | "effect": "fadeOut" 430 | } 431 | }, 432 | "output": { 433 | "format": "mp4", 434 | "resolution": "sd" 435 | } 436 | } 437 | --------------------------------------------------------------------------------