├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── components ├── App.tsx ├── Button.tsx ├── CreateButton.tsx ├── ImageOption.tsx ├── SelectInput.tsx ├── SettingsPanel.tsx └── TextInput.tsx ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── api │ └── videos.ts └── index.tsx ├── public └── favicon.ico ├── styles └── globals.css ├── tsconfig.json └── utility ├── deepClone.ts └── useWindowWidth.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # JetBrains editors 39 | /.idea 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Creatomate 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video Preview Demo 2 | 3 | Add video rendering to your web apps! Seamlessly integrate our video renderer into your software and provide your users with video editing functionality – right in the browser. 4 | 5 | This is a demo application showing how a dynamic video can be previewed in the browser using the [Preview SDK](https://creatomate.com/javascript-video-sdk). The code can be used as a basis for creating your own video editor applications using Creatomate's API. 6 | 7 | ## Demo 8 | 9 | Try it out live: https://video-preview-demo.vercel.app 10 | 11 | The **Create Video** button is disabled in the live demo as this requires an API key. To run the example with your own API key, follow the instructions below. 12 | 13 | ## Usage 14 | 15 | ### Running this demo application 16 | 17 | This demo uses a video template from your account. The example code demonstrates a few features that require a specific template, so be sure to follow the instructions carefully: 18 | 19 | 1. Create a free account [here](https://creatomate.com/sign-in). 20 | 2. Go to your project settings, and copy your **API Key** and **Public Token** under *Programmatic Access*:

![Screenshot](https://user-images.githubusercontent.com/44575638/227715496-5ae23468-c047-4ab8-beb2-e21b6c65d74b.png)

21 | 3. In your dashboard, go to **Templates**, click **New**, go to the **Featured** category, and choose the **"Image Slideshow w/ Intro and Outro"** template, then click **Create Template**:

![template-screenshot](https://github.com/Creatomate/video-preview-demo/assets/44575638/10841294-b85d-47cd-bf7c-a435092e2abf)

22 | 4. From the address bar, copy the ID of the newly created template:

![Screenshot](https://user-images.githubusercontent.com/44575638/227736758-f9d522c3-3bbb-4b7b-92c7-e004e9dc16e5.png)

23 | 5. Create a new file called `.env.local` in the root of the project, providing the **API Key**, **Public Token**, and **Template ID** from the previous steps: 24 | 25 | ``` 26 | CREATOMATE_API_KEY=... 27 | NEXT_PUBLIC_CREATOMATE_PUBLIC_TOKEN=... 28 | NEXT_PUBLIC_TEMPLATE_ID=... 29 | ``` 30 | 31 | 6. Install all NPM dependencies using the following command: 32 | 33 | ```bash 34 | npm install 35 | ``` 36 | 37 | 7. The demo can then be run using: 38 | 39 | ```bash 40 | npm run dev 41 | ``` 42 | 43 | 8. Now visit the URL that is displayed in your console, which is by default: `http://localhost:3000` 44 | 45 | ### Using this code in your own projects 46 | 47 | Install the Preview SDK using the following command: 48 | 49 | ```bash 50 | npm install @creatomate/preview 51 | ``` 52 | 53 | Please refer to [App.tsx](https://github.com/Creatomate/video-preview-demo/blob/main/components/App.tsx) to see an example of how to initialize the SDK. 54 | 55 | ## Issues & Comments 56 | 57 | Feel free to contact us if you encounter any issues with this demo or Creatomate API at [support@creatomate.com](mailto:support@creatomate.com). 58 | 59 | ## License 60 | 61 | This demo is licensed under the MIT license. Please refer to the [LICENSE](https://github.com/Creatomate/video-preview-demo/blob/main/LICENSE) for more information. 62 | -------------------------------------------------------------------------------- /components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Preview, PreviewState } from '@creatomate/preview'; 4 | import { useWindowWidth } from '../utility/useWindowWidth'; 5 | import { SettingsPanel } from './SettingsPanel'; 6 | 7 | const App: React.FC = () => { 8 | // React Hook to update the component when the window width changes 9 | const windowWidth = useWindowWidth(); 10 | 11 | // Video aspect ratio that can be calculated once the video is loaded 12 | const [videoAspectRatio, setVideoAspectRatio] = useState(); 13 | 14 | // Reference to the preview 15 | const previewRef = useRef(); 16 | 17 | // Current state of the preview 18 | const [isReady, setIsReady] = useState(false); 19 | const [isLoading, setIsLoading] = useState(true); 20 | const [currentState, setCurrentState] = useState(); 21 | 22 | // This sets up the video player in the provided HTML DIV element 23 | const setUpPreview = (htmlElement: HTMLDivElement) => { 24 | if (previewRef.current) { 25 | previewRef.current.dispose(); 26 | previewRef.current = undefined; 27 | } 28 | 29 | // Initialize a preview 30 | const preview = new Preview(htmlElement, 'player', process.env.NEXT_PUBLIC_CREATOMATE_PUBLIC_TOKEN!); 31 | 32 | // Once the SDK is ready, load a template from our project 33 | preview.onReady = async () => { 34 | await preview.loadTemplate(process.env.NEXT_PUBLIC_TEMPLATE_ID!); 35 | setIsReady(true); 36 | }; 37 | 38 | preview.onLoad = () => { 39 | setIsLoading(true); 40 | }; 41 | 42 | preview.onLoadComplete = () => { 43 | setIsLoading(false); 44 | }; 45 | 46 | // Listen for state changes of the preview 47 | preview.onStateChange = (state) => { 48 | setCurrentState(state); 49 | setVideoAspectRatio(state.width / state.height); 50 | }; 51 | 52 | previewRef.current = preview; 53 | }; 54 | 55 | return ( 56 | 57 | 58 | { 60 | if (htmlElement && htmlElement !== previewRef.current?.element) { 61 | setUpPreview(htmlElement); 62 | } 63 | }} 64 | style={{ 65 | height: 66 | videoAspectRatio && windowWidth && windowWidth < 768 ? window.innerWidth / videoAspectRatio : undefined, 67 | }} 68 | /> 69 | 70 | 71 | 72 | {isReady && ( 73 | 74 | 75 | 76 | )} 77 | 78 | 79 | {isLoading && Loading...} 80 | 81 | ); 82 | }; 83 | 84 | export default App; 85 | 86 | const Component = styled.div` 87 | width: 100vw; 88 | height: 100vh; 89 | display: flex; 90 | flex-direction: column; 91 | 92 | @media (min-width: 768px) { 93 | flex-direction: row; 94 | } 95 | `; 96 | 97 | const Wrapper = styled.div` 98 | display: flex; 99 | 100 | @media (min-width: 768px) { 101 | flex: 1; 102 | padding: 20px; 103 | } 104 | `; 105 | 106 | const Container = styled.div` 107 | width: 100%; 108 | height: 100%; 109 | max-width: 720px; 110 | max-height: 720px; 111 | margin: auto; 112 | `; 113 | 114 | const Panel = styled.div` 115 | flex: 1; 116 | position: relative; 117 | background: #fff; 118 | box-shadow: rgba(0, 0, 0, 0.1) 0 6px 15px 0; 119 | 120 | @media (min-width: 768px) { 121 | flex: initial; 122 | margin: 50px; 123 | width: 400px; 124 | border-radius: 15px; 125 | } 126 | `; 127 | 128 | const PanelContent = styled.div` 129 | position: absolute; 130 | left: 0; 131 | top: 0; 132 | width: 100%; 133 | height: 100%; 134 | padding: 20px; 135 | overflow: auto; 136 | `; 137 | 138 | const LoadIndicator = styled.div` 139 | position: fixed; 140 | top: 20px; 141 | left: 50%; 142 | transform: translateX(-50%); 143 | padding: 5px 15px; 144 | background: #fff; 145 | box-shadow: rgba(0, 0, 0, 0.1) 0 6px 15px 0; 146 | border-radius: 5px; 147 | font-size: 15px; 148 | font-weight: 600; 149 | 150 | @media (min-width: 768px) { 151 | top: 50px; 152 | left: calc((100% - 400px) / 2); 153 | } 154 | `; 155 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export const Button = styled.button` 5 | padding: 10px 15px; 6 | border: none; 7 | background: #0065eb; 8 | border-radius: 5px; 9 | color: #fff; 10 | font-size: 16px; 11 | font-weight: 500; 12 | cursor: pointer; 13 | `; 14 | -------------------------------------------------------------------------------- /components/CreateButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Preview } from '@creatomate/preview'; 4 | import { Button } from './Button'; 5 | 6 | interface CreateButtonProps { 7 | preview: Preview; 8 | } 9 | 10 | export const CreateButton: React.FC = (props) => { 11 | const [isRendering, setIsRendering] = useState(false); 12 | const [render, setRender] = useState(); 13 | 14 | if (isRendering) { 15 | return Rendering...; 16 | } 17 | 18 | if (render) { 19 | return ( 20 | { 23 | window.open(render.url, '_blank'); 24 | setRender(undefined); 25 | }} 26 | > 27 | Download 28 | 29 | ); 30 | } 31 | 32 | return ( 33 | { 36 | setIsRendering(true); 37 | 38 | try { 39 | const render = await finishVideo(props.preview); 40 | if (render.status === 'succeeded') { 41 | setRender(render); 42 | } else { 43 | window.alert(`Rendering failed: ${render.errorMessage}`); 44 | } 45 | } catch (error) { 46 | window.alert(error); 47 | } finally { 48 | setIsRendering(false); 49 | } 50 | }} 51 | > 52 | Create Video 53 | 54 | ); 55 | }; 56 | 57 | const Component = styled(Button)` 58 | display: block; 59 | margin-left: auto; 60 | `; 61 | 62 | const finishVideo = async (preview: Preview) => { 63 | const response = await fetch('/api/videos', { 64 | method: 'POST', 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | body: JSON.stringify({ 69 | source: preview.getSource(), 70 | }), 71 | }); 72 | 73 | if (!response.ok) { 74 | if (response.status === 401) { 75 | throw new Error('No API key was provided. Please refer to the README.md for instructions.'); 76 | } else { 77 | throw new Error(`The request failed with status code ${response.status}`); 78 | } 79 | } 80 | 81 | return await response.json(); 82 | }; 83 | -------------------------------------------------------------------------------- /components/ImageOption.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export const ImageOption = styled.div<{ url: string }>` 5 | margin: 0 10px; 6 | width: 65px; 7 | height: 65px; 8 | border-radius: 5px; 9 | background: url('${(props) => props.url}'); 10 | background-size: cover; 11 | cursor: pointer; 12 | `; 13 | -------------------------------------------------------------------------------- /components/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export const SelectInput = styled.select` 5 | display: block; 6 | margin: 5px 0; 7 | padding: 10px 15px; 8 | width: 100%; 9 | background-color: #fff; 10 | border: 1px solid #b3bfcc; 11 | border-radius: 5px; 12 | outline: none; 13 | 14 | // Arrow 15 | appearance: none; 16 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 10'%3E%3Cpath d='m0.993 2.02 5.25 5.25c0.966 0.966 2.534 0.966 3.5-0l5.264-5.264' fill='none' stroke='%23000' stroke-width='2px'/%3E%3C/svg%3E"); 17 | background-repeat: no-repeat; 18 | background-position: right 12px top 50%; 19 | background-size: 16px auto; 20 | 21 | &:focus { 22 | background-color: #e9f4fc; 23 | border-color: #005aff; 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /components/SettingsPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useMemo, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Preview, PreviewState } from '@creatomate/preview'; 4 | import { deepClone } from '../utility/deepClone'; 5 | import { TextInput } from './TextInput'; 6 | import { SelectInput } from './SelectInput'; 7 | import { ImageOption } from './ImageOption'; 8 | import { Button } from './Button'; 9 | import { CreateButton } from './CreateButton'; 10 | 11 | interface SettingsPanelProps { 12 | preview: Preview; 13 | currentState?: PreviewState; 14 | } 15 | 16 | export const SettingsPanel: React.FC = (props) => { 17 | // In this variable, we store the modifications that are applied to the template 18 | // Refer to: https://creatomate.com/docs/api/rest-api/the-modifications-object 19 | const modificationsRef = useRef>({}); 20 | 21 | // Get the slide elements in the template by name (starting with 'Slide-') 22 | const slideElements = useMemo(() => { 23 | return props.currentState?.elements.filter((element) => element.source.name?.startsWith('Slide-')); 24 | }, [props.currentState]); 25 | 26 | return ( 27 |
28 | 29 | 30 | 31 | Intro 32 | ensureElementVisibility(props.preview, 'Title', 1.5)} 35 | onChange={(e) => setPropertyValue(props.preview, 'Title', e.target.value, modificationsRef.current)} 36 | /> 37 | ensureElementVisibility(props.preview, 'Tagline', 1.5)} 40 | onChange={(e) => setPropertyValue(props.preview, 'Tagline', e.target.value, modificationsRef.current)} 41 | /> 42 | ensureElementVisibility(props.preview, 'Start-Text', 1.5)} 45 | onChange={(e) => setPropertyValue(props.preview, 'Start-Text', e.target.value, modificationsRef.current)} 46 | /> 47 | 48 | 49 | 50 | Outro 51 | ensureElementVisibility(props.preview, 'Final-Text', 1.5)} 54 | onChange={(e) => setPropertyValue(props.preview, 'Final-Text', e.target.value, modificationsRef.current)} 55 | /> 56 | 57 | 58 | {slideElements?.map((slideElement, i) => { 59 | const transitionAnimation = slideElement.source.animations.find((animation: any) => animation.transition); 60 | 61 | const nestedElements = props.preview.getElements(slideElement); 62 | const textElement = nestedElements.find((element) => element.source.name?.endsWith('-Text')); 63 | const imageElement = nestedElements.find((element) => element.source.name?.endsWith('-Image')); 64 | 65 | return ( 66 | 67 | Slide {i + 1} 68 | {textElement && ( 69 | 70 | ensureElementVisibility(props.preview, textElement.source.name, 1.5)} 73 | onChange={(e) => 74 | setPropertyValue(props.preview, textElement.source.name, e.target.value, modificationsRef.current) 75 | } 76 | /> 77 | ensureElementVisibility(props.preview, textElement.source.name, 1.5)} 79 | onChange={(e) => 80 | setTextStyle(props.preview, textElement.source.name, e.target.value, modificationsRef.current) 81 | } 82 | > 83 | 84 | 85 | 86 | ensureElementVisibility(props.preview, slideElement.source.name, 0.5)} 89 | onChange={(e) => setSlideTransition(props.preview, slideElement.source.name, e.target.value)} 90 | > 91 | 92 | 93 | 94 | {imageElement && ( 95 | 96 | {[ 97 | 'https://creatomate-static.s3.amazonaws.com/demo/harshil-gudka-77zGnfU_SFU-unsplash.jpg', 98 | 'https://creatomate-static.s3.amazonaws.com/demo/samuel-ferrara-1527pjeb6jg-unsplash.jpg', 99 | 'https://creatomate-static.s3.amazonaws.com/demo/simon-berger-UqCnDyc_3vA-unsplash.jpg', 100 | ].map((url) => ( 101 | { 105 | await ensureElementVisibility(props.preview, imageElement.source.name, 1.5); 106 | await setPropertyValue( 107 | props.preview, 108 | imageElement.source.name, 109 | url, 110 | modificationsRef.current, 111 | ); 112 | }} 113 | /> 114 | ))} 115 | 116 | )} 117 | 118 | )} 119 | 120 | ); 121 | })} 122 | 123 | 126 |
127 | ); 128 | }; 129 | 130 | const Group = styled.div` 131 | margin: 20px 0; 132 | padding: 20px; 133 | background: #f5f7f8; 134 | border-radius: 5px; 135 | `; 136 | 137 | const GroupTitle = styled.div` 138 | margin-bottom: 15px; 139 | font-weight: 600; 140 | `; 141 | 142 | const ImageOptions = styled.div` 143 | display: flex; 144 | margin: 20px -10px 0 -10px; 145 | `; 146 | 147 | // Updates the provided modifications object 148 | const setPropertyValue = async ( 149 | preview: Preview, 150 | selector: string, 151 | value: string, 152 | modifications: Record, 153 | ) => { 154 | if (value.trim()) { 155 | // If a non-empty value is passed, update the modifications based on the provided selector 156 | modifications[selector] = value; 157 | } else { 158 | // If an empty value is passed, remove it from the modifications map, restoring its default 159 | delete modifications[selector]; 160 | } 161 | 162 | // Set the template modifications 163 | await preview.setModifications(modifications); 164 | }; 165 | 166 | // Sets the text styling properties 167 | // For a full list of text properties, refer to: https://creatomate.com/docs/json/elements/text-element 168 | const setTextStyle = async (preview: Preview, selector: string, style: string, modifications: Record) => { 169 | if (style === 'block-text') { 170 | modifications[`${selector}.background_border_radius`] = '0%'; 171 | } else if (style === 'rounded-text') { 172 | modifications[`${selector}.background_border_radius`] = '50%'; 173 | } 174 | 175 | await preview.setModifications(modifications); 176 | }; 177 | 178 | // Jumps to a time position where the provided element is visible 179 | const ensureElementVisibility = async (preview: Preview, elementName: string, addTime: number) => { 180 | // Find element by name 181 | const element = preview.getElements().find((element) => element.source.name === elementName); 182 | if (element) { 183 | // Set playback time 184 | await preview.setTime(element.globalTime + addTime); 185 | } 186 | }; 187 | 188 | // Sets the animation of a slide element 189 | const setSlideTransition = async (preview: Preview, slideName: string, type: string) => { 190 | // Make sure to clone the state as it's immutable 191 | const mutatedState = deepClone(preview.state); 192 | 193 | // Find element by name 194 | const element = preview.getElements(mutatedState).find((element) => element.source.name === slideName); 195 | if (element) { 196 | // Set the animation property 197 | // Refer to: https://creatomate.com/docs/json/elements/common-properties 198 | element.source.animations = [ 199 | { 200 | type, 201 | duration: 1, 202 | transition: true, 203 | }, 204 | ]; 205 | 206 | // Update the video source 207 | // Refer to: https://creatomate.com/docs/json/introduction 208 | await preview.setSource(preview.getSource(mutatedState)); 209 | } 210 | }; 211 | 212 | const addSlide = async (preview: Preview) => { 213 | // Get the video source 214 | // Refer to: https://creatomate.com/docs/json/introduction 215 | const source = preview.getSource(); 216 | 217 | // Delete the 'duration' and 'time' property values to make each element (Slide-1, Slide-2, etc.) autosize on the timeline 218 | delete source.duration; 219 | for (const element of source.elements) { 220 | delete element.time; 221 | } 222 | 223 | // Find the last slide element (e.g. Slide-3) 224 | const lastSlideIndex = source.elements.findLastIndex((element: any) => element.name?.startsWith('Slide-')); 225 | if (lastSlideIndex !== -1) { 226 | const slideName = `Slide-${lastSlideIndex}`; 227 | 228 | // Create a new slide 229 | const newSlideSource = createSlide(slideName, `This is the text caption for newly added slide ${lastSlideIndex}.`); 230 | 231 | // Insert the new slide 232 | source.elements.splice(lastSlideIndex + 1, 0, newSlideSource); 233 | 234 | // Update the video source 235 | await preview.setSource(source); 236 | 237 | // Jump to the time at which the text element is visible 238 | await ensureElementVisibility(preview, `${slideName}-Text`, 1.5); 239 | 240 | // Scroll to the bottom of the settings panel 241 | const panel = document.querySelector('#panel'); 242 | if (panel) { 243 | panel.scrollTop = panel.scrollHeight; 244 | } 245 | } 246 | }; 247 | 248 | const createSlide = (slideName: string, caption: string) => { 249 | // This is the JSON of a new slide. It is based on existing slides in the "Image Slideshow w/ Intro and Outro" template. 250 | // Refer to: https://creatomate.com/docs/json/introduction 251 | return { 252 | name: slideName, 253 | type: 'composition', 254 | track: 1, 255 | duration: 4, 256 | animations: [ 257 | { 258 | type: 'fade', 259 | duration: 1, 260 | transition: true, 261 | }, 262 | ], 263 | elements: [ 264 | { 265 | name: `${slideName}-Image`, 266 | type: 'image', 267 | animations: [ 268 | { 269 | easing: 'linear', 270 | type: 'scale', 271 | fade: false, 272 | scope: 'element', 273 | end_scale: '130%', 274 | start_scale: '100%', 275 | }, 276 | ], 277 | source: 'https://creatomate-static.s3.amazonaws.com/demo/samuel-ferrara-1527pjeb6jg-unsplash.jpg', 278 | }, 279 | { 280 | name: `${slideName}-Text`, 281 | type: 'text', 282 | time: 0.5, 283 | duration: 3.5, 284 | y: '83.3107%', 285 | width: '70%', 286 | height: '10%', 287 | x_alignment: '50%', 288 | y_alignment: '100%', 289 | fill_color: '#ffffff', 290 | animations: [ 291 | { 292 | time: 'start', 293 | duration: 1, 294 | easing: 'quadratic-out', 295 | type: 'text-slide', 296 | scope: 'split-clip', 297 | split: 'line', 298 | direction: 'up', 299 | background_effect: 'scaling-clip', 300 | }, 301 | { 302 | easing: 'linear', 303 | type: 'scale', 304 | fade: false, 305 | scope: 'element', 306 | y_anchor: '100%', 307 | end_scale: '130%', 308 | start_scale: '100%', 309 | }, 310 | ], 311 | text: caption, 312 | font_family: 'Roboto Condensed', 313 | font_weight: '700', 314 | background_color: 'rgba(220,171,94,1)', 315 | background_x_padding: '80%', 316 | }, 317 | ], 318 | }; 319 | }; 320 | -------------------------------------------------------------------------------- /components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export const TextInput = styled.textarea` 5 | display: block; 6 | margin: 5px 0; 7 | padding: 15px; 8 | width: 100%; 9 | height: 75px; 10 | border: 1px solid #b3bfcc; 11 | border-radius: 5px; 12 | outline: none; 13 | resize: none; 14 | 15 | &:focus { 16 | background: #e9f4fc; 17 | border-color: #005aff; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | compiler: { 6 | styledComponents: true, 7 | }, 8 | }; 9 | 10 | module.exports = nextConfig; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-preview-demo", 3 | "version": "1.0.0", 4 | "author": "Creatomate", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "devDependencies": { 13 | "@types/lodash": "^4.14.191", 14 | "@types/node": "^18.11.18", 15 | "@types/react": "^18.0.26", 16 | "@types/react-dom": "^18.0.10", 17 | "@types/styled-components": "^5.1.26", 18 | "prettier": "^2.8.1" 19 | }, 20 | "dependencies": { 21 | "@creatomate/preview": "^1.5.0", 22 | "creatomate": "^1.1.0", 23 | "eslint": "^8.30.0", 24 | "eslint-config-next": "^12.0.4", 25 | "lodash": "^4.17.21", 26 | "modern-normalize": "^1.1.0", 27 | "next": "^13.1.1", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-draggable": "^4.4.5", 31 | "styled-components": "^5.3.6", 32 | "typescript": "^4.9.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /pages/api/videos.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Client, RenderOutputFormat } from 'creatomate'; 3 | 4 | const client = new Client(process.env.CREATOMATE_API_KEY!); 5 | 6 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 7 | return new Promise((resolve) => { 8 | if (req.method === 'POST') { 9 | 10 | // Return an HTTP 401 response when the API key was not provided 11 | if (!process.env.CREATOMATE_API_KEY) { 12 | res.status(401).end(); 13 | resolve(); 14 | return; 15 | } 16 | 17 | /** @type {import('creatomate').RenderOptions} */ 18 | const options = { 19 | // outputFormat: 'mp4' as RenderOutputFormat, 20 | source: req.body.source, 21 | }; 22 | 23 | client 24 | .render(options) 25 | .then((renders) => { 26 | res.status(200).json(renders[0]); 27 | resolve(); 28 | }) 29 | .catch(() => { 30 | res.status(400).end(); 31 | resolve(); 32 | }); 33 | } else { 34 | res.status(404).end(); 35 | resolve(); 36 | } 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import dynamic from 'next/dynamic'; 3 | 4 | const App = dynamic(() => import('../components/App'), { ssr: false }); 5 | 6 | export default function Home() { 7 | return ( 8 |
9 | 10 | Video Preview Demo 11 | 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatomate/video-preview-demo/028ba2824c5f50f0e44be6bcea9e048bb754cd4e/public/favicon.ico -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import 'node_modules/modern-normalize/modern-normalize.css'; 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); 3 | 4 | html { 5 | background-color: #e4e7eb; 6 | user-select: none; 7 | } 8 | 9 | body { 10 | font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 11 | 'Segoe UI Emoji'; 12 | font-size: 18px; 13 | line-height: 1.5; 14 | } 15 | 16 | a { 17 | color: #fff; 18 | text-underline-offset: 3px; 19 | } 20 | 21 | *::-webkit-scrollbar { 22 | width: 8px; 23 | height: 8px; 24 | } 25 | 26 | *::-webkit-scrollbar-track { 27 | background: #dedede; 28 | } 29 | 30 | *::-webkit-scrollbar-thumb { 31 | background: #c0c0c0; 32 | } 33 | 34 | *::-webkit-scrollbar-corner { 35 | background: rgba(0, 0, 0, 0); 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /utility/deepClone.ts: -------------------------------------------------------------------------------- 1 | export function deepClone(value: T): T { 2 | if (typeof value !== 'object' || value === null) { 3 | return value; 4 | } 5 | 6 | const target: any = Array.isArray(value) ? [] : {}; 7 | 8 | for (const key in value) { 9 | target[key] = deepClone(value[key]); 10 | } 11 | 12 | return target; 13 | } 14 | -------------------------------------------------------------------------------- /utility/useWindowWidth.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useWindowWidth() { 4 | 5 | const [windowWidth, setWindowWidth] = useState(); 6 | useEffect(() => { 7 | setWindowWidth(window.innerWidth); 8 | 9 | const handleResize = () => { 10 | setWindowWidth(window.innerWidth); 11 | }; 12 | 13 | window.addEventListener('resize', handleResize); 14 | return () => window.removeEventListener('resize', handleResize); 15 | }, []); 16 | 17 | return windowWidth; 18 | } 19 | --------------------------------------------------------------------------------