├── 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-mkv-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(uniqid() + '-' + data.name, data.type); 11 | return response.format(200, 'success', 'OK', presignedPostData); 12 | } catch (err) { 13 | console.error('Fail: ', err); 14 | return response.format(400, 'fail', 'Bad Request', err); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /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": "mkv" 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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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/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 | } -------------------------------------------------------------------------------- /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 | }) 18 | .then((response) => { 19 | return resolve(response.data) 20 | }, (error) => { 21 | return reject(error) 22 | }); 23 | }) 24 | } 25 | 26 | const status = (id) => { 27 | return new Promise((resolve, reject) => { 28 | axios({ 29 | method: 'get', 30 | url: shotstackUrl + 'sources/' + id, 31 | headers: { 32 | 'x-api-key': shotstackApiKey 33 | } 34 | }) 35 | .then((response) => { 36 | return resolve(response.data.data.attributes); 37 | }), (error) => { 38 | return reject(error); 39 | } 40 | }) 41 | } 42 | 43 | module.exports = { 44 | submit, 45 | status 46 | } 47 | -------------------------------------------------------------------------------- /api/serverless.yml: -------------------------------------------------------------------------------- 1 | service: demo-mp4-mkv-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 mkv 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 mkv 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({ status: 'fail', message: 'bad request', data: error.message || error }); 29 | } 30 | }); 31 | 32 | app.get('/demo/shotstack/:id', async (req, res) => { 33 | try { 34 | const response = await shotstack.status(req.params.id); 35 | 36 | res.header('Access-Control-Allow-Origin', '*'); 37 | res.status(200); 38 | res.send({ status: 'success', message: 'OK', data: response }); 39 | } catch (error) { 40 | console.log(error); 41 | res.header('Access-Control-Allow-Origin', '*'); 42 | res.status(400); 43 | res.send({ status: 'fail', message: 'bad request', data: error }); 44 | } 45 | }); 46 | 47 | app.post('/demo/upload/sign', async (req, res) => { 48 | try { 49 | const data = req.body; 50 | const presignedPostData = await upload.createPresignedPost( 51 | uniqid() + '-' + data.name, 52 | data.type 53 | ); 54 | 55 | res.header('Access-Control-Allow-Origin', '*'); 56 | res.status(200); 57 | res.send({ status: 'success', message: 'OK', data: presignedPostData }); 58 | } catch (error) { 59 | console.log(error); 60 | res.header('Access-Control-Allow-Origin', '*'); 61 | res.status(400); 62 | res.send({ status: 'fail', message: 'bad request', data: error.message }); 63 | } 64 | }); 65 | 66 | app.listen(3000, () => 67 | console.log('Server running...\n\nOpen http://localhost:3000 in your browser\n') 68 | ); 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shotstack MP4 to MKV Demo 2 | 3 | This sample application demonstrates how you can use the Shotstack Ingest API to [convert MP4 videos to 4 | MKV](https://shotstack.io/demo/mp4-to-mkv/) format. The Ingest API is a [video transcoding 5 | API](https://shotstack.io/product/ingest-api/) that enables uploading, storing, and converting videos and images into 6 | various formats, sizes, frame rates, speeds, and more. 7 | 8 | Using an HTML web form, users can upload a video or provide the URL to an MP4 video online. Upon form submission, the 9 | video is transformed from MP4 to MKV. The newly converted file available for download. 10 | 11 | Explore the live demo at: https://shotstack.io/demo/mp4-to-mkv/ 12 | 13 | This demo is built using Node.js and the Express Framework, or it can also be deployed as a serverless project using AWS 14 | Lambda and API 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 in to a 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 MKV file is ready the status is updated and the file URL of 30 | the MKV 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. jQuery is used for the interactive components of the application and to submit the data to the backend API and poll 38 | the status. There is also a video player that plays the final MKV file video file when ready. 39 | 40 | The front end API source code 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 front end form (from the **api** directory): 70 | 71 | ```bash 72 | npm run start 73 | ``` 74 | 75 | Then visit [http://localhost:3000](http://localhost:3000) 76 | 77 | 78 | ### Deploy Serverless Application (optional) 79 | 80 | The project has been built as a serverless application using the Serverless Framework and AWS Lambda. To understand more 81 | about the Serverless Framework and how to set everything up consult the documentation: 82 | https://serverless.com/framework/docs/providers/aws/ 83 | 84 | To deploy to AWS Lambda (from the **api** directory): 85 | 86 | ```bash 87 | npm run deploy 88 | ``` 89 | 90 | Once the API is deployed set the `var apiEndpoint` variable in **web/app.js** to the returned API Gateway URL. 91 | 92 | Run the **web/index.html** file locally or use AWS S3 static hosting to serve the web page. 93 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 17 | 23 | 29 | 30 | 31 | 32 | Convert MP4 to MKV Shotstack Demo 33 | 37 | 38 | 39 |
40 |
41 |
42 |
43 |
44 | 45 | 46 | Max file size: 250MB 47 | 48 |
49 |
50 | 66 |
67 |
68 | 79 |
80 |
81 | 92 | 98 |
99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 | 107 | 108 | 111 |
112 |
113 | 117 | Get the Source Code 121 | 122 |
123 |
124 |
125 |
126 |
127 |

Download your converted MP4 to MKV file here when ready.

128 |
129 |
130 |
131 |
132 | 133 |

134 |
135 |
142 |
143 | Hold tight, processing may take a minute... 144 |
145 |
146 |
147 |
148 |

149 | 156 | Download File 157 | 158 | 168 |

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