├── api ├── .env.dist ├── package.json ├── src │ ├── handler │ │ ├── upload │ │ │ ├── handler.js │ │ │ └── lib │ │ │ │ └── upload.js │ │ └── shotstack │ │ │ ├── lib │ │ │ ├── convert.js │ │ │ └── shotstack.js │ │ │ └── handler.js │ ├── shared │ │ └── response.js │ └── app.js └── serverless.yml ├── .gitignore ├── LICENSE ├── web ├── styles.css ├── index.html └── app.js └── README.md /api/.env.dist: -------------------------------------------------------------------------------- 1 | SHOTSTACK_API_KEY=YOUR_API_KEY 2 | SHOTSTACK_HOST=https://api.shotstack.io/ingest/stage/ 3 | AWS_S3_UPLOADS_BUCKET=YOUR_S3_BUCKET 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | .DS_Store 4 | .tags 5 | 6 | # package directories 7 | node_modules 8 | jspm_packages 9 | 10 | # Serverless directories 11 | .serverless -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mp4-to-webm-demo", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "./node_modules/.bin/serverless deploy", 7 | "start": "node src/app.js" 8 | }, 9 | "dependencies": { 10 | "aws-sdk": "^2.1484.0", 11 | "axios": "^0.21.4", 12 | "dotenv": "^8.6.0", 13 | "express": "^4.18.2", 14 | "joi": "^17.11.0", 15 | "mime": "^2.6.0", 16 | "uniqid": "^5.4.0" 17 | }, 18 | "devDependencies": { 19 | "serverless": "^3.36.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/src/handler/upload/handler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const uniqid = require("uniqid"); 4 | const response = require("../../shared/response"); 5 | const upload = require("./lib/upload"); 6 | 7 | module.exports.getPresignedPostData = async (event) => { 8 | try { 9 | const data = JSON.parse(event.body); 10 | const presignedPostData = await upload.createPresignedPost( 11 | uniqid() + "-" + data.name, 12 | data.type 13 | ); 14 | return response.format(200, "success", "OK", presignedPostData); 15 | } catch (err) { 16 | console.error("Fail: ", err); 17 | return response.format(400, "fail", "Bad Request", err); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/lib/convert.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Joi = require("joi"); 4 | 5 | const validateBody = (body) => { 6 | const schema = Joi.object({ 7 | video: Joi.string().uri().min(2).max(300).required(), 8 | }); 9 | 10 | return schema.validate({ ...body }); 11 | }; 12 | 13 | const prepareRequestJson = (body) => { 14 | const valid = validateBody(body); 15 | 16 | if (valid.error) { 17 | throw new Error(valid.error.details[0].message); 18 | } 19 | 20 | const { video: videoUrl } = body; 21 | 22 | return { 23 | url: videoUrl, 24 | outputs: { 25 | renditions: [ 26 | { 27 | format: "webm", 28 | }, 29 | ], 30 | }, 31 | }; 32 | }; 33 | 34 | module.exports = { 35 | prepareRequestJson, 36 | }; 37 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/handler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const response = require("../../shared/response"); 4 | const shotstack = require("./lib/shotstack"); 5 | const convert = require("./lib/convert"); 6 | 7 | module.exports.submit = async (event) => { 8 | try { 9 | const data = JSON.parse(event.body); 10 | const json = convert.prepareRequestJson(data); 11 | const transformation = await shotstack.submit(json); 12 | 13 | return response.format(201, "success", "OK", transformation.data); 14 | } catch (error) { 15 | console.error(error); 16 | return response.format(400, "fail", "Bad Request", error.message || error); 17 | } 18 | }; 19 | 20 | module.exports.status = async (event) => { 21 | try { 22 | const status = await shotstack.status(event.pathParameters.id); 23 | 24 | return response.format(201, "success", "OK", status); 25 | } catch (error) { 26 | console.error("Fail: ", error); 27 | return response.format(400, "fail", "Bad Request", error); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /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 | "Access-Control-Allow-Credentials": true, 35 | }, 36 | body: JSON.stringify(this.getBody(status, message, data)), 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /api/src/handler/upload/lib/upload.js: -------------------------------------------------------------------------------- 1 | const S3 = require("aws-sdk/clients/s3"); 2 | 3 | const s3 = new S3(); 4 | const awsBucket = process.env.AWS_S3_UPLOADS_BUCKET; 5 | const MIN_FILE_SIZE = 10; 6 | const MAX_FILE_SIZE = 2500000000; 7 | 8 | /** 9 | * Use AWS SDK to create pre-signed POST data. 10 | * We also put a file size limit (1kB - 250MB). 11 | * @param key 12 | * @param contentType 13 | * @returns {Promise} 14 | */ 15 | const createPresignedPost = (key, contentType) => { 16 | const params = { 17 | Expires: 60, 18 | Bucket: awsBucket, 19 | Conditions: [["content-length-range", MIN_FILE_SIZE, MAX_FILE_SIZE]], 20 | Fields: { 21 | "Content-Type": contentType, 22 | key: key, 23 | }, 24 | }; 25 | 26 | return new Promise((resolve, reject) => { 27 | try { 28 | s3.createPresignedPost(params, (err, data) => { 29 | if (err) { 30 | reject(err); 31 | } 32 | resolve(data); 33 | }); 34 | } catch (err) { 35 | reject(err); 36 | } 37 | }); 38 | }; 39 | 40 | module.exports = { 41 | createPresignedPost, 42 | }; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 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. 22 | -------------------------------------------------------------------------------- /api/src/handler/shotstack/lib/shotstack.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const axios = require("axios").default; 4 | const shotstackUrl = process.env.SHOTSTACK_HOST; 5 | const shotstackApiKey = process.env.SHOTSTACK_API_KEY; 6 | 7 | const submit = (json) => { 8 | return new Promise((resolve, reject) => { 9 | axios({ 10 | method: "post", 11 | url: shotstackUrl + "sources", 12 | headers: { 13 | "x-api-key": shotstackApiKey, 14 | "content-type": "application/json", 15 | }, 16 | data: json, 17 | }).then( 18 | (response) => { 19 | return resolve(response.data); 20 | }, 21 | (error) => { 22 | return reject(error); 23 | } 24 | ); 25 | }); 26 | }; 27 | 28 | const status = (id) => { 29 | return new Promise((resolve, reject) => { 30 | axios({ 31 | method: "get", 32 | url: shotstackUrl + "sources/" + id, 33 | headers: { 34 | "x-api-key": shotstackApiKey, 35 | }, 36 | }).then((response) => { 37 | return resolve(response.data.data.attributes); 38 | }), 39 | (error) => { 40 | return reject(error); 41 | }; 42 | }); 43 | }; 44 | 45 | module.exports = { 46 | submit, 47 | status, 48 | }; 49 | -------------------------------------------------------------------------------- /api/serverless.yml: -------------------------------------------------------------------------------- 1 | service: demo-mp4-webm-shotstack 2 | useDotenv: true 3 | 4 | provider: 5 | name: aws 6 | runtime: nodejs16.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 | iamRoleStatements: 14 | - Effect: Allow 15 | Action: 16 | - s3:PutObject 17 | Resource: arn:aws:s3:::${env:AWS_S3_UPLOADS_BUCKET}/* 18 | environment: 19 | SHOTSTACK_API_KEY: ${env:SHOTSTACK_API_KEY} 20 | SHOTSTACK_HOST: ${env:SHOTSTACK_HOST} 21 | AWS_S3_UPLOADS_BUCKET: ${env:AWS_S3_UPLOADS_BUCKET} 22 | 23 | package: 24 | exclude: 25 | - .env 26 | - .env.dist 27 | - package.json 28 | - package-lock.json 29 | - src/app.js 30 | - node_modules/aws-sdk/** 31 | - node_modules/**/aws-sdk/** 32 | 33 | functions: 34 | shotstack: 35 | handler: src/handler/shotstack/handler.submit 36 | description: Demo - Convert mp4 to webm 37 | timeout: 15 38 | memorySize: 128 39 | events: 40 | - http: 41 | path: shotstack 42 | method: post 43 | cors: true 44 | status: 45 | handler: src/handler/shotstack/handler.status 46 | description: Demo - Convert mp4 to webm status check 47 | timeout: 10 48 | memorySize: 128 49 | events: 50 | - http: 51 | path: shotstack/{id} 52 | method: get 53 | cors: true 54 | upload: 55 | handler: src/handler/upload/handler.getPresignedPostData 56 | description: Demo - Get presigned url for asset upload 57 | timeout: 15 58 | memorySize: 128 59 | events: 60 | - http: 61 | path: upload/sign 62 | method: post 63 | cors: true 64 | -------------------------------------------------------------------------------- /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, 19 | #json { 20 | display: none; 21 | } 22 | 23 | #status .fas { 24 | margin-bottom: 8px; 25 | } 26 | 27 | #status small { 28 | margin-top: 10px; 29 | color: #777777; 30 | } 31 | 32 | .progress { 33 | height: 2px; 34 | margin-bottom: 14px; 35 | } 36 | 37 | #json { 38 | margin-top: 16px; 39 | } 40 | 41 | #json .card { 42 | background-color: #fafafa; 43 | border-radius: 10px; 44 | } 45 | 46 | #json pre { 47 | margin: 0; 48 | } 49 | 50 | .json-key { 51 | color: brown; 52 | } 53 | .json-value { 54 | color: navy; 55 | } 56 | .json-string { 57 | color: olive; 58 | } 59 | 60 | header { 61 | margin-bottom: 2rem; 62 | } 63 | 64 | .video-btn-group .btn.active { 65 | background-color: #25d3d0 !important; 66 | border-color: #25d3d0 !important; 67 | } 68 | 69 | a { 70 | color: #25d3d0; 71 | } 72 | a:hover { 73 | color: #25d3d0; 74 | } 75 | 76 | .video-container .jumbotron { 77 | background-color: transparent; 78 | } 79 | 80 | #instructions, 81 | #status { 82 | margin-top: 3rem; 83 | } 84 | 85 | #instructions p { 86 | margin: 0; 87 | } 88 | 89 | input[name='video-url'], 90 | #video-url, 91 | .file-placeholder { 92 | display: none; 93 | } 94 | 95 | .toggle-button-container { 96 | height: 100px; 97 | } 98 | 99 | .info { 100 | cursor: pointer; 101 | } 102 | 103 | label { 104 | font-weight: 600; 105 | } 106 | 107 | #file-delta-summary table { 108 | width: 100%; 109 | } 110 | td.value { 111 | text-align: right; 112 | } 113 | 114 | .alert-info { 115 | color: #212529; 116 | background-color: #eceff2; 117 | border-color: #dfe3e8; 118 | } 119 | -------------------------------------------------------------------------------- /api/src/app.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const express = require("express"); 4 | const path = require("path"); 5 | const uniqid = require("uniqid"); 6 | const shotstack = require("./handler/shotstack/lib/shotstack"); 7 | const convert = require("./handler/shotstack/lib/convert"); 8 | const upload = require("./handler/upload/lib/upload"); 9 | 10 | const app = express(); 11 | 12 | app.use(express.json()); 13 | app.use(express.urlencoded({ extended: false })); 14 | app.use(express.static(path.join(__dirname + "../../../web"))); 15 | 16 | app.post("/demo/shotstack", async (req, res) => { 17 | try { 18 | const json = convert.prepareRequestJson(req.body); 19 | const response = await shotstack.submit(json); 20 | 21 | res.header("Access-Control-Allow-Origin", "*"); 22 | res.status(201); 23 | res.send({ status: "success", message: "OK", data: response.data }); 24 | } catch (error) { 25 | console.log(error); 26 | res.header("Access-Control-Allow-Origin", "*"); 27 | res.status(400); 28 | res.send({ 29 | status: "fail", 30 | message: "bad request", 31 | data: error.message || error, 32 | }); 33 | } 34 | }); 35 | 36 | app.get("/demo/shotstack/:id", async (req, res) => { 37 | try { 38 | const response = await shotstack.status(req.params.id); 39 | 40 | res.header("Access-Control-Allow-Origin", "*"); 41 | res.status(200); 42 | res.send({ status: "success", message: "OK", data: response }); 43 | } catch (error) { 44 | console.log(error); 45 | res.header("Access-Control-Allow-Origin", "*"); 46 | res.status(400); 47 | res.send({ status: "fail", message: "bad request", data: error }); 48 | } 49 | }); 50 | 51 | app.post("/demo/upload/sign", async (req, res) => { 52 | try { 53 | const data = req.body; 54 | const presignedPostData = await upload.createPresignedPost( 55 | uniqid() + "-" + data.name, 56 | data.type 57 | ); 58 | 59 | res.header("Access-Control-Allow-Origin", "*"); 60 | res.status(200); 61 | res.send({ status: "success", message: "OK", data: presignedPostData }); 62 | } catch (error) { 63 | console.log(error); 64 | res.header("Access-Control-Allow-Origin", "*"); 65 | res.status(400); 66 | res.send({ status: "fail", message: "bad request", data: error.message }); 67 | } 68 | }); 69 | 70 | app.listen(3000, () => 71 | console.log( 72 | "Server running...\n\nOpen http://localhost:3000 in your browser\n" 73 | ) 74 | ); 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shotstack MP4 to WEBM Demo 2 | 3 | This sample application shows how you can use the Shotstack Ingest API to [convert MP4 videos to 4 | WEBM](https://shotstack.io/demo/mp4-to-webm/) format. The Ingest API is a [video transformation 5 | API](https://shotstack.io/product/ingest-api/) that lets you upload, store, and convert videos and images into different 6 | formats, sizes, frame rates, speeds, and more. 7 | 8 | Using an HTML web form, users can upload a video or put in a URL of an MP4 video online. After submitting the form, the 9 | video is converted from MP4 to WEBM. Users can play the new WEBM file in their browsers or download it. 10 | 11 | View the live demo at: https://shotstack.io/demo/mp4-to-webm/ 12 | 13 | The demo is made with Node.js and works with the Express Framework or as a serverless project using AWS Lambda and API 14 | Gateway. 15 | 16 | ### Requirements 17 | 18 | - Node 16+ 19 | - Shotstack API key: https://dashboard.shotstack.io/register 20 | 21 | ### Project Structure 22 | 23 | The project is divided into two components: 24 | 25 | #### Backend API 26 | 27 | The backend API has an endpoint that receives the video file URL. The parameters are prepared in the [JSON 28 | format](https://shotstack.io/docs/api/#tocs_source) the Ingest API expects and sent to the API. A status endpoint is 29 | called to check the progress of the transformation. When the WEBM file is ready, the status is updated and the file URL 30 | of the WEBM file is returned. 31 | 32 | The backend API source code is in the **api** directory. 33 | 34 | #### Frontend Web Form & Player 35 | 36 | The frontend uses Bootstrap and basic HTML to set up a form that allows the user to upload a video file or enter a file 37 | URL. We use jQuery for the interactive components of the application, to submit the data to the backend API and poll the 38 | status. There is also a video player that plays the final WEBM video file when it's ready. 39 | 40 | The source code for the frontend is in the **web** directory. 41 | 42 | ### Installation 43 | 44 | Install node module dependencies: 45 | 46 | ```bash 47 | cd api 48 | npm install 49 | ``` 50 | 51 | ### Configuration 52 | 53 | Copy the `.env.dist` file and rename it `.env`: 54 | 55 | ``` 56 | cp .env.dist .env 57 | ``` 58 | 59 | Replace the environment variables below with your Shotstack API key (stage key) and a writable S3 bucket name: 60 | 61 | ```bash 62 | SHOTSTACK_API_KEY=replace_with_your_shotstack_key 63 | SHOTSTACK_HOST=https://api.shotstack.io/ingest/stage/ 64 | AWS_S3_UPLOADS_BUCKET=replace_with_an_s3_bucket_name 65 | ``` 66 | 67 | ### Run Locally 68 | 69 | To start the API and serve the frontend form (from the **api** directory): 70 | 71 | Navigate to the **api** directory. And run the command below to start the API and serve the frontend form. 72 | 73 | ```bash 74 | npm run start 75 | ``` 76 | 77 | Then 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 and AWS Lambda. To understand more 83 | about the Serverless Framework and how to set everything up consult the documentation: 84 | https://serverless.com/framework/docs/providers/aws/ 85 | 86 | To deploy to AWS Lambda (from the **api** directory): 87 | 88 | ```bash 89 | npm run deploy 90 | ``` 91 | 92 | Once the API is deployed set the `var apiEndpoint` variable in **web/app.js** to the returned API Gateway URL. 93 | 94 | Run the **web/index.html** file locally or use AWS S3 static hosting to serve the web page. 95 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 17 | 23 | 29 | 30 | 31 | 32 | 33 | Convert MP4 to WEBM Shotstack Demo 34 | 38 | 39 | 40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 | Max file size: 250MB 48 | 49 |
50 |
51 | 67 |
68 |
69 | 80 |
81 |
82 | 93 | 99 |
100 | 101 | 102 | 103 | 104 |
105 |
106 | 107 | 108 | 109 | 112 |
113 |
114 | 118 | Get the Source Code 122 | 123 |
124 |
125 |
126 |
127 |
128 |

Your converted MP4 to WEBM file will play here.

129 |
130 |
131 |
132 |
133 | 134 |

135 |
136 |
143 |
144 | Hold tight, processing may take a minute... 145 |
146 |
147 | 148 |
149 |
150 |

151 | 158 | Download File 159 | 160 | 170 |

171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | 182 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | var apiUrl = "http://localhost:3000/demo/"; // 'https://hajw55amz3.execute-api.ap-southeast-2.amazonaws.com/demo/'; 2 | var apiEndpoint = apiUrl + "shotstack"; 3 | var urlEndpoint = apiUrl + "upload/sign"; 4 | var s3Bucket = "https://shotstack-demo-storage.s3-ap-southeast-2.amazonaws.com/"; 5 | var progress = 0; 6 | var progressIncrement = 10; 7 | var pollIntervalSeconds = 10; 8 | var unknownError = "An error has occurred, please try again later."; 9 | var player; 10 | 11 | /** 12 | * Initialise and play the video file 13 | * 14 | * @param {String} src the video file URL 15 | */ 16 | function initialiseVideo(src) { 17 | player = new Plyr("#player", { 18 | controls: [ 19 | "play-large", 20 | "play", 21 | "progress", 22 | "mute", 23 | "volume", 24 | "download", 25 | "fullscreen", 26 | ], 27 | }); 28 | 29 | player.source = { 30 | type: "video", 31 | sources: [ 32 | { 33 | src: src, 34 | type: "video/mp4", 35 | }, 36 | ], 37 | }; 38 | 39 | player.download = src; 40 | 41 | $("#status").removeClass("d-flex").addClass("d-none"); 42 | $("#player").show(); 43 | 44 | player.play(); 45 | } 46 | 47 | /** 48 | * Check the status of the video processing 49 | * 50 | * @param {String} id the job id 51 | */ 52 | function pollVideoStatus(id) { 53 | $.get(apiEndpoint + "/" + id, function (response) { 54 | var rendition = response.data.outputs.renditions[0]; 55 | var status = rendition.status; 56 | 57 | updateStatus(status); 58 | 59 | if (!(status === "ready" || status === "failed")) { 60 | setTimeout(function () { 61 | pollVideoStatus(id); 62 | }, pollIntervalSeconds * 1000); 63 | } else if (status === "failed") { 64 | updateStatus(status); 65 | } else { 66 | initialiseVideo(rendition.url); 67 | initialiseJson(rendition.transformation); 68 | initialiseDownload(rendition.url); 69 | 70 | resetForm(); 71 | } 72 | }); 73 | } 74 | 75 | /** 76 | * Update status message and progress bar 77 | * 78 | * @param {String} status the status text 79 | */ 80 | function updateStatus(status) { 81 | $("#status").removeClass("d-none"); 82 | $("#instructions").addClass("d-none"); 83 | 84 | if (progress <= 90) { 85 | progress += progressIncrement; 86 | } 87 | 88 | if (status === "submitted") { 89 | $("#status .fas").attr("class", "fas fa-spinner fa-spin fa-2x"); 90 | $("#status p").text("SUBMITTED"); 91 | } else if (status === "queued") { 92 | $("#status .fas").attr("class", "fas fa-history fa-2x"); 93 | $("#status p").text("QUEUED"); 94 | } else if (status === "waiting") { 95 | $("#status .fas").attr("class", "fas fa-cloud-download-alt fa-2x"); 96 | $("#status p").text("IMPORTING FILE"); 97 | } else if (status === "processing") { 98 | $("#status .fas").attr("class", "fas fa-server fa-2x"); 99 | $("#status p").text("PROCESSING"); 100 | } else if (status === "ready") { 101 | $("#status .fas").attr("class", "fas fa-check-circle fa-2x"); 102 | $("#status p").text("READY"); 103 | progress = 100; 104 | } else { 105 | $("#status .fas").attr("class", "fas fa-exclamation-triangle fa-2x"); 106 | $("#status p").text("SOMETHING WENT WRONG"); 107 | $("#submit-video").prop("disabled", false); 108 | progress = 0; 109 | } 110 | 111 | $(".progress-bar") 112 | .css("width", progress + "%") 113 | .attr("aria-valuenow", progress); 114 | } 115 | 116 | /** 117 | * Display form field and general errors returned by API 118 | * 119 | * @param error 120 | */ 121 | function displayError(error) { 122 | if (typeof error === "string") { 123 | $("#errors").text(error).removeClass("d-hide").addClass("d-block"); 124 | return; 125 | } 126 | 127 | updateStatus(null); 128 | 129 | if (error.status === 400) { 130 | var response = error.responseJSON; 131 | if (typeof response.data === "string") { 132 | $("#errors") 133 | .text(response.data) 134 | .removeClass("d-hide") 135 | .addClass("d-block"); 136 | } else { 137 | $("#errors").text(unknownError).removeClass("d-hide").addClass("d-block"); 138 | } 139 | } else { 140 | $("#errors").text(unknownError).removeClass("d-hide").addClass("d-block"); 141 | } 142 | } 143 | 144 | /** 145 | * Reset errors 146 | */ 147 | function resetErrors() { 148 | $("input, label, select").removeClass("text-danger is-invalid"); 149 | $(".invalid-feedback").remove(); 150 | $("#errors").text("").removeClass("d-block").addClass("d-none"); 151 | } 152 | 153 | /** 154 | * Reset form 155 | */ 156 | function resetForm() { 157 | $("#submit-video").prop("disabled", false); 158 | } 159 | 160 | /** 161 | * Reset and delete video 162 | */ 163 | function resetVideo() { 164 | if (player) { 165 | player.destroy(); 166 | player = undefined; 167 | } 168 | 169 | progress = 0; 170 | 171 | $(".json-container").html(""); 172 | $("#json").hide(); 173 | } 174 | 175 | /** 176 | * Submit the form with data to transform the video to WEBM 177 | */ 178 | function submitVideoTransformation() { 179 | updateStatus("submitted"); 180 | $("#submit-video").prop("disabled", true); 181 | $("#file-delta-summary").addClass("d-none"); 182 | 183 | var formData = { 184 | video: getSelectedVideoFile(), 185 | }; 186 | 187 | $.ajax({ 188 | type: "POST", 189 | url: apiEndpoint, 190 | data: JSON.stringify(formData), 191 | dataType: "json", 192 | crossDomain: true, 193 | contentType: "application/json", 194 | }) 195 | .done(function (response) { 196 | if (response.status !== "success") { 197 | displayError(response.message); 198 | $("#submit-video").prop("disabled", false); 199 | } else { 200 | pollVideoStatus(response.data.id); 201 | } 202 | }) 203 | .fail(function (error) { 204 | displayError(error); 205 | $("#submit-video").prop("disabled", false); 206 | }); 207 | } 208 | 209 | /** 210 | * Colour and style JSON 211 | * 212 | * @param match 213 | * @param pIndent 214 | * @param pKey 215 | * @param pVal 216 | * @param pEnd 217 | * @returns {*} 218 | */ 219 | function styleJson(match, pIndent, pKey, pVal, pEnd) { 220 | var key = '"'; 221 | var val = ""; 222 | var str = ""; 223 | var r = pIndent || ""; 224 | if (pKey) r = r + key + pKey.replace(/[": ]/g, "") + '": '; 225 | if (pVal) r = r + (pVal[0] == '"' ? str : val) + pVal + ""; 226 | return r + (pEnd || ""); 227 | } 228 | 229 | /** 230 | * Pretty print JSON object on screen 231 | * 232 | * @param obj 233 | * @returns {string} 234 | */ 235 | function prettyPrintJson(obj) { 236 | var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/gm; 237 | return JSON.stringify(obj, null, 3) 238 | .replace(/&/g, "&") 239 | .replace(/\\"/g, """) 240 | .replace(//g, ">") 242 | .replace(jsonLine, styleJson); 243 | } 244 | 245 | /** 246 | * Show the JSON display button 247 | * 248 | * @param json 249 | */ 250 | function initialiseJson(json) { 251 | $("#json").show(); 252 | $(".json-container").html(prettyPrintJson(json)); 253 | } 254 | 255 | /** 256 | * Open file in new window 257 | * 258 | * @param {String} url 259 | */ 260 | function initialiseDownload(url) { 261 | $("#download").attr("href", url); 262 | } 263 | 264 | /** 265 | * Set URL to active 266 | * @param {Object} $urlButton 267 | */ 268 | function setUrlActive($urlButton) { 269 | var $parent = $urlButton.closest(".video-group"); 270 | var $videoUrlField = $parent.children(".input-url"); 271 | var $uploadField = $parent.children(".upload"); 272 | 273 | $urlButton.addClass("btn-primary").removeClass("btn-secondary"); 274 | $videoUrlField.prop("required", true); 275 | $uploadField.removeAttr("required"); 276 | $videoUrlField.slideDown("fast"); 277 | } 278 | 279 | /** 280 | * Set URL to inactive 281 | * @param {Object} $urlButton 282 | */ 283 | function setUrlInactive($urlButton) { 284 | var $parent = $urlButton.closest(".video-group"); 285 | var $videoUrlField = $parent.children(".input-url"); 286 | 287 | $urlButton.removeClass("btn-primary").addClass("btn-secondary"); 288 | $videoUrlField.removeAttr("required"); 289 | $videoUrlField.slideUp("fast"); 290 | } 291 | 292 | /** 293 | * Set upload to active 294 | * @param {Object} $uploadButton 295 | */ 296 | function setUploadActive($uploadButton) { 297 | var $parent = $uploadButton.closest(".video-group"); 298 | var $videoUrlField = $parent.children(".input-url"); 299 | var $uploadField = $parent.find(".upload"); 300 | var $filePlaceholder = $parent.children(".file-placeholder"); 301 | 302 | $uploadButton.addClass("btn-primary").removeClass("btn-secondary"); 303 | $videoUrlField.removeAttr("required"); 304 | $uploadField.prop("required", true); 305 | $filePlaceholder.slideDown("fast"); 306 | } 307 | 308 | /** 309 | * Set Upload to inactive 310 | * @param {Object} $uploadButton 311 | */ 312 | function setUploadInactive($uploadButton) { 313 | var $parent = $uploadButton.closest(".video-group"); 314 | var $uploadField = $parent.find(".upload"); 315 | var $filePlaceholder = $parent.children(".file-placeholder"); 316 | 317 | $uploadButton.removeClass("btn-primary").addClass("btn-secondary"); 318 | $uploadField.removeAttr("required"); 319 | $filePlaceholder.slideUp("fast"); 320 | } 321 | 322 | /** 323 | * Remove a file from upload 324 | * 325 | * @param {*} $removeButton 326 | */ 327 | function removeFile($removeButton) { 328 | var $uploadButton = $removeButton 329 | .closest(".video-group") 330 | .find(".upload-button"); 331 | var $filename = $removeButton.siblings(".name"); 332 | 333 | setUploadInactive($uploadButton); 334 | $filename.empty().removeAttr("data-file"); 335 | } 336 | 337 | /** 338 | * Get the URL of the selected video file 339 | */ 340 | function getSelectedVideoFile() { 341 | var $videoUrl = $("#video-url"); 342 | var $videoFile = $("#video-upload"); 343 | 344 | if ($videoUrl.prop("required")) { 345 | return $videoUrl.val(); 346 | } 347 | 348 | if ($videoFile.prop("required")) { 349 | var $videoFileName = $("#video-file .name"); 350 | return s3Bucket + encodeURIComponent($videoFileName.attr("data-file")); 351 | } 352 | } 353 | 354 | /** 355 | * Upload a file to AWS S3 356 | * 357 | * @param {String} file 358 | * @param {Object} presignedPostData 359 | * @param {Object} element 360 | */ 361 | function uploadFileToS3(file, presignedPostData, element) { 362 | var $uploadField = $(element); 363 | var $parent = $uploadField.closest(".video-group"); 364 | var $uploadButton = $parent.find(".upload-button"); 365 | var $loadingSpinner = $uploadButton.find(".loading-image"); 366 | var $uploadIcon = $uploadButton.find(".upload-icon"); 367 | var $filePlaceholder = $parent.children(".file-placeholder"); 368 | var $filePlaceholderName = $filePlaceholder.children(".name"); 369 | 370 | var formData = new FormData(); 371 | Object.keys(presignedPostData.fields).forEach((key) => { 372 | formData.append(key, presignedPostData.fields[key]); 373 | }); 374 | formData.append("file", file); 375 | 376 | $loadingSpinner.removeClass("d-none"); 377 | $uploadIcon.addClass("d-none"); 378 | 379 | $.ajax({ 380 | url: presignedPostData.url, 381 | method: "POST", 382 | data: formData, 383 | contentType: false, 384 | processData: false, 385 | }) 386 | .done(function (response, statusText, xhr) { 387 | $loadingSpinner.addClass("d-none"); 388 | $uploadIcon.removeClass("d-none"); 389 | if (xhr.status === 204) { 390 | setUploadActive($uploadButton); 391 | $filePlaceholderName 392 | .text(file.name) 393 | .attr("data-file", presignedPostData.fields["key"]); 394 | } else { 395 | console.log(xhr.status); 396 | } 397 | }) 398 | .fail(function (error) { 399 | console.error(error); 400 | displayError("Failed to upload file to S3"); 401 | }); 402 | } 403 | 404 | /** 405 | * Get an AWS signed URL for S3 uploading 406 | * 407 | * @param {String} name 408 | * @param {String} type 409 | * @param {*} callback 410 | */ 411 | function getS3PresignedPostData(name, type, callback) { 412 | var formData = { 413 | name, 414 | type, 415 | }; 416 | 417 | $.ajax({ 418 | type: "POST", 419 | url: urlEndpoint, 420 | data: JSON.stringify(formData), 421 | dataType: "json", 422 | crossDomain: true, 423 | contentType: "application/json", 424 | }) 425 | .done(function (response) { 426 | if (response.status !== "success") { 427 | displayError(response.message); 428 | } else { 429 | callback(response.data); 430 | } 431 | }) 432 | .fail(function (error) { 433 | console.error(error); 434 | displayError("Failed to generate S3 signed URL"); 435 | }); 436 | } 437 | 438 | /** 439 | * Check form is valid 440 | */ 441 | function isFormValid() { 442 | $requiredFields = $(".video-group").find("input[required]"); 443 | 444 | if ($requiredFields.length !== 1) { 445 | return false; 446 | } 447 | 448 | return true; 449 | } 450 | 451 | /** 452 | * Event Handlers 453 | */ 454 | $(document).ready(function () { 455 | /** URL button click event */ 456 | $(".url-button").click(function () { 457 | var $urlButton = $(this); 458 | var $parent = $urlButton.closest(".video-group"); 459 | var $videoUrlField = $parent.children(".input-url"); 460 | var $uploadButton = $parent.find(".upload-button"); 461 | 462 | setUploadInactive($uploadButton); 463 | 464 | if ($videoUrlField.is(":hidden")) { 465 | setUrlActive($urlButton); 466 | } else { 467 | setUrlInactive($urlButton); 468 | } 469 | }); 470 | 471 | /** Upload button click event */ 472 | $(".upload-button").click(function (event) { 473 | var $uploadButton = $(this); 474 | var $parent = $uploadButton.closest(".video-group"); 475 | var $uploadField = $parent.find(".upload"); 476 | var $urlButton = $parent.find(".url-button"); 477 | 478 | setUrlInactive($urlButton); 479 | $uploadField.prop("required", true).click(); 480 | 481 | event.preventDefault(); 482 | }); 483 | 484 | /** Remove file button click event */ 485 | $(".remove-file").click(function () { 486 | removeFile($(this)); 487 | }); 488 | 489 | /** File upload change event */ 490 | $(".upload").change(function (event) { 491 | var name = event.target.files[0].name; 492 | var type = event.target.files[0].type; 493 | 494 | getS3PresignedPostData(name, type, function (data) { 495 | uploadFileToS3(event.target.files[0], data, event.target); 496 | }); 497 | }); 498 | 499 | /** Video URL field change event */ 500 | $("#video-url").blur(function () { 501 | var videoUrl = $(this).val(); 502 | }); 503 | 504 | /** Form submit event */ 505 | $("form").submit(function (event) { 506 | if (isFormValid()) { 507 | resetErrors(); 508 | resetVideo(); 509 | submitVideoTransformation(); 510 | } else { 511 | displayError("Please select a video."); 512 | } 513 | 514 | event.preventDefault(); 515 | }); 516 | }); 517 | --------------------------------------------------------------------------------