├── .dockerignore ├── .eslintrc ├── .gitignore ├── .gitattributes ├── src ├── sounds │ ├── tab1.mp3 │ ├── tab2.mp3 │ ├── tab3.mp3 │ └── collapse.mp3 ├── index.tsx ├── heart.svg ├── Background.tsx ├── fetch-msgs.ts ├── get-image-url.ts ├── Video.tsx ├── upload-and-notify.ts ├── get-avatar-initials.tsx ├── math.ts ├── Avatar.tsx ├── Story.tsx └── Message.tsx ├── remotion.config.ts ├── .prettierrc ├── .vscode └── settings.json ├── tsconfig.json ├── Dockerfile ├── .github └── workflows │ └── render-video.yml ├── README.md ├── package.json └── server.tsx /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@remotion" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .env 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/sounds/tab1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JonnyBurger/programmatic-stories/HEAD/src/sounds/tab1.mp3 -------------------------------------------------------------------------------- /src/sounds/tab2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JonnyBurger/programmatic-stories/HEAD/src/sounds/tab2.mp3 -------------------------------------------------------------------------------- /src/sounds/tab3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JonnyBurger/programmatic-stories/HEAD/src/sounds/tab3.mp3 -------------------------------------------------------------------------------- /src/sounds/collapse.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JonnyBurger/programmatic-stories/HEAD/src/sounds/collapse.mp3 -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import {registerRoot} from 'remotion'; 2 | import {RemotionVideo} from './Video'; 3 | 4 | registerRoot(RemotionVideo); 5 | -------------------------------------------------------------------------------- /remotion.config.ts: -------------------------------------------------------------------------------- 1 | import {Config} from 'remotion'; 2 | 3 | Config.Output.setCodec('h264'); 4 | Config.Output.setImageSequence(false); 5 | Config.Rendering.setImageFormat('jpeg'); 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": false, 4 | "jsxBracketSameLine": false, 5 | "useTabs": true, 6 | "overrides": [ 7 | { 8 | "files": ["*.yml"], 9 | "options": { 10 | "singleQuote": false 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "java.configuration.updateBuildConfiguration": "disabled", 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": false, 7 | "source.fixAll": true 8 | }, 9 | "typescript.enablePromptUseWorkspaceTsdk": true 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "jsx": "react-jsx", 6 | "outDir": "./dist", 7 | "strict": true, 8 | "noEmit": true, 9 | "lib": ["ES2015", "DOM"], 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Background.tsx: -------------------------------------------------------------------------------- 1 | export const Background: React.FC = () => { 2 | return ( 3 |
13 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/fetch-msgs.ts: -------------------------------------------------------------------------------- 1 | import {messageStart} from './math'; 2 | import {SingleMessageApiResponse} from './Message'; 3 | 4 | export const getDuration = async (messageIds: string[]) => { 5 | const messages = await Promise.all( 6 | messageIds.map(async (m) => { 7 | const response = await fetch( 8 | `https://bestande.ch/api/chat/messages/${m}` 9 | ); 10 | const json = await response.json(); 11 | return json.data as SingleMessageApiResponse; 12 | }) 13 | ); 14 | 15 | const entrance = messageStart(messages, messages.length - 1); 16 | 17 | return entrance + 100; 18 | }; 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is a dockerized version of a server that you can easily deploy somewhere. 2 | # If you don't want server rendering, you can safely delete this file. 3 | 4 | FROM node:alpine 5 | 6 | # Installs latest Chromium (85) package. 7 | RUN apk add --no-cache \ 8 | chromium \ 9 | nss \ 10 | freetype \ 11 | freetype-dev \ 12 | harfbuzz \ 13 | ca-certificates \ 14 | ttf-freefont \ 15 | ffmpeg 16 | 17 | # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. 18 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ 19 | PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 20 | 21 | COPY package*.json ./ 22 | COPY tsconfig.json ./ 23 | COPY src src 24 | COPY *.ts . 25 | COPY *.tsx . 26 | 27 | RUN npm i 28 | 29 | RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \ 30 | && mkdir -p /home/pptruser/Downloads /app \ 31 | && chown -R pptruser:pptruser /home/pptruser \ 32 | && chown -R pptruser:pptruser /app 33 | # Run everything after as non-privileged user. 34 | USER pptruser 35 | 36 | EXPOSE 8000 37 | 38 | CMD ["npm", "run", "server"] 39 | -------------------------------------------------------------------------------- /src/get-image-url.ts: -------------------------------------------------------------------------------- 1 | import memoize from 'lodash/memoize'; 2 | import pickBy from 'lodash/pickBy'; 3 | import qs from 'qs'; 4 | 5 | const IMGIX_DOMAIN = 'https://img.bestande.ch'; 6 | 7 | export const getCdnUrlWithOptions = ( 8 | domain: string = IMGIX_DOMAIN, 9 | cdn_identifier: string, 10 | options: Record 11 | ): string => { 12 | return `${domain}/${cdn_identifier}?${qs.stringify(pickBy(options))}`; 13 | }; 14 | 15 | export const getImageUrl = memoize( 16 | ({ 17 | height, 18 | width, 19 | cdn_identifier, 20 | format, 21 | crop, 22 | domain = IMGIX_DOMAIN, 23 | fit = 'crop', 24 | }: { 25 | height: number | 'auto'; 26 | width: number | 'auto'; 27 | cdn_identifier: string; 28 | crop: 'faces' | null; 29 | format?: string; 30 | domain?: string; 31 | fit?: 'crop' | 'clamp'; 32 | }): string => { 33 | const options = { 34 | w: width, 35 | h: height, 36 | crop, 37 | fit, 38 | fm: format, 39 | auto: 'format', 40 | q: 50, 41 | }; 42 | return getCdnUrlWithOptions(domain, cdn_identifier, options); 43 | } 44 | ); 45 | -------------------------------------------------------------------------------- /.github/workflows/render-video.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | inputs: 4 | messageIds: 5 | description: "Which message ids should it be?" 6 | required: true 7 | default: "b077588c-488f-458c-bf5a-33585850c3ae,dcd83c57-aa47-45a2-b41d-0bd8d3f95724" 8 | name: Render video 9 | jobs: 10 | render: 11 | name: Render video 12 | runs-on: macos-latest 13 | steps: 14 | - uses: actions/checkout@main 15 | - uses: actions/setup-node@main 16 | - uses: FedericoCarboni/setup-ffmpeg@v1 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | - run: npm i 20 | - run: npm run build -- --props="$WORKFLOW_INPUT" 21 | env: 22 | WORKFLOW_INPUT: ${{ toJson(github.event.inputs) }} 23 | - run: npx ts-node src/upload-and-notify.ts 24 | env: 25 | SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} 26 | S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} 27 | S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} 28 | - uses: actions/upload-artifact@v2 29 | with: 30 | name: out.mp4 31 | path: out.mp4 32 | -------------------------------------------------------------------------------- /src/Video.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import { 3 | Composition, 4 | continueRender, 5 | delayRender, 6 | getInputProps, 7 | } from 'remotion'; 8 | import {getDuration} from './fetch-msgs'; 9 | import {Story} from './Story'; 10 | 11 | export const RemotionVideo: React.FC = () => { 12 | const props = getInputProps(); 13 | const [handle] = useState(() => delayRender()); 14 | 15 | const messages = props?.messageIds 16 | ? props.messageIds.split(',') 17 | : '12697cdc-60cb-4860-9bd5-930c965a7abd,1817905d-a33f-42ec-a0d2-152db5b78325,0ed77e51-11a1-497a-b5fa-63538f6cd0c8'.split( 18 | ',' 19 | ); 20 | 21 | const [duration, setDuration] = useState(null); 22 | 23 | useEffect(() => { 24 | getDuration(messages).then((d) => { 25 | setDuration(Math.floor(d)); 26 | continueRender(handle); 27 | }); 28 | }, [handle, messages]); 29 | 30 | return ( 31 | <> 32 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/upload-and-notify.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | import dotenv from 'dotenv'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | // @ts-expect-error slackbot doesn't have types 6 | import Slackbot from 'slackbot'; 7 | import xns from 'xns'; 8 | 9 | dotenv.config(); 10 | 11 | xns(async () => { 12 | const s3 = new AWS.S3({ 13 | accessKeyId: process.env.S3_ACCESS_KEY, 14 | secretAccessKey: process.env.S3_SECRET_KEY, 15 | }); 16 | const result = await new Promise( 17 | (resolve, reject) => { 18 | s3.upload( 19 | { 20 | Bucket: 'bestande-stories', 21 | Key: `video-${Date.now()}.mp4`, 22 | Body: fs.readFileSync(path.join(__dirname, '..', 'out.mp4')), 23 | ACL: 'public-read', 24 | }, 25 | (err, data) => { 26 | if (err) { 27 | reject(err); 28 | } else { 29 | resolve(data); 30 | } 31 | } 32 | ); 33 | } 34 | ); 35 | const slackbot = process.env.SLACK_TOKEN 36 | ? new Slackbot('hackercompany', process.env.SLACK_TOKEN) 37 | : null; 38 | slackbot.send( 39 | '#bestande-stories', 40 | ['New story available:', result.Location].join('\n') 41 | ); 42 | await new Promise((resolve) => setTimeout(resolve, 2000)); 43 | }); 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Watch the tuturial](https://www.youtube.com/watch?v=70UdF6DWY3M) 2 | ## [Watch a sample video](https://www.youtube.com/watch?v=70UdF6DWY3M) 3 | 4 | # Remotion video 5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 | Welcome to your Remotion project! 13 | 14 | ## Commands 15 | 16 | **Start Preview** 17 | 18 | ```console 19 | npm start 20 | ``` 21 | 22 | **Render video** 23 | 24 | ```console 25 | npm run build 26 | ``` 27 | 28 | **Server render demo** 29 | 30 | ```console 31 | npm run server 32 | ``` 33 | 34 | See [docs for server-side rendering](https://www.remotion.dev/docs/ssr) here. 35 | 36 | **Upgrade Remotion** 37 | 38 | ```console 39 | npm run upgrade 40 | ``` 41 | 42 | ## Docs 43 | 44 | Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals). 45 | 46 | ## Issues 47 | 48 | Found an issue with Remotion? [File an issue here](https://github.com/JonnyBurger/remotion/issues/new). 49 | 50 | ## License 51 | 52 | Notice that a company license is needed. Read [the terms here](https://github.com/JonnyBurger/remotion/blob/main/LICENSE.md). 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remotion-template", 3 | "version": "1.0.0", 4 | "description": "My Remotion video", 5 | "scripts": { 6 | "start": "remotion preview src/index.tsx", 7 | "build": "remotion render src/index.tsx Story out.mp4", 8 | "upgrade": "remotion upgrade", 9 | "server": "ts-node server.tsx", 10 | "test": "eslint src --ext ts,tsx,js,jsx && tsc" 11 | }, 12 | "repository": {}, 13 | "license": "UNLICENSED", 14 | "dependencies": { 15 | "@remotion/bundler": "^2.1.2", 16 | "@remotion/cli": "^2.1.2", 17 | "@remotion/eslint-config": "^2.1.0", 18 | "@remotion/renderer": "^2.1.2", 19 | "@types/express": "^4.17.9", 20 | "@types/lodash": "^4.14.169", 21 | "@types/react": "^17.0.0", 22 | "@types/react-native": "^0.63.50", 23 | "@types/styled-components": "^5.1.7", 24 | "aws-sdk": "^2.853.0", 25 | "date-fns": "^2.17.0", 26 | "dotenv": "^8.2.0", 27 | "eslint": "^7.15.0", 28 | "express": "^4.17.1", 29 | "lodash": "^4.17.21", 30 | "polished": "^4.1.1", 31 | "prettier": "^2.2.1", 32 | "prettier-plugin-organize-imports": "^1.1.1", 33 | "react": "^17.0.1", 34 | "react-dom": "^17.0.2", 35 | "react-markdown": "^5.0.3", 36 | "react-native": "^0.63.4", 37 | "react-native-dom": "^0.5.0", 38 | "react-native-web": "^0.15.0", 39 | "remotion": "^2.1.2", 40 | "slackbot": "0.0.2", 41 | "styled-components": "^5.2.1", 42 | "ts-node": "^9.1.1", 43 | "typescript": "^4.1.3", 44 | "xns": "^2.0.7" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/get-avatar-initials.tsx: -------------------------------------------------------------------------------- 1 | export const getAvatarInitials = (username: string): string => { 2 | if (username.match(/boomer/i)) { 3 | return '💥'; 4 | } 5 | if (username.match(/hänger/i)) { 6 | return '🎮'; 7 | } 8 | if (username.match(/asvz/i)) { 9 | return '🏋️'; 10 | } 11 | if (username.match(/influencer/i)) { 12 | return '🤳'; 13 | } 14 | if (username.match(/einstein/i)) { 15 | return '🧑‍🔬'; 16 | } 17 | if (username.match(/avocado/i)) { 18 | return '🥑'; 19 | } 20 | if (username.match(/woman/i)) { 21 | return '👩'; 22 | } 23 | if (username.match(/girl/i)) { 24 | return '👧'; 25 | } 26 | if (username.match(/professor/i)) { 27 | return '👨‍🏫'; 28 | } 29 | if (username.match(/bqm/i)) { 30 | return '🍻'; 31 | } 32 | if (username.match(/winniepooh/i)) { 33 | return '🐻'; 34 | } 35 | if (username.match(/döner/i)) { 36 | return '🥙'; 37 | } 38 | if (username.match(/kebab/i)) { 39 | return '🥙'; 40 | } 41 | if (username.match(/kebap/i)) { 42 | return '🥙'; 43 | } 44 | if (username.match(/chiller/i)) { 45 | return '⛱'; 46 | } 47 | if (username.match(/kermit/i)) { 48 | return '🐸'; 49 | } 50 | if (username.match(/420/i)) { 51 | return '🍁'; 52 | } 53 | if (username.match(/orange/i)) { 54 | return '🍊'; 55 | } 56 | if (username.match(/abc/i)) { 57 | return '🔤'; 58 | } 59 | if (username.match(/zürch/i)) { 60 | return '🏙'; 61 | } 62 | if (username.match(/zurich/i)) { 63 | return '🏙'; 64 | } 65 | if (username.match(/uzh/i)) { 66 | return '🏫'; 67 | } 68 | if (username.match(/banana/i)) { 69 | return '🍌'; 70 | } 71 | if (username.match(/studi/i)) { 72 | return '🎓'; 73 | } 74 | if (username.match(/bachelor/i)) { 75 | return '🎓'; 76 | } 77 | if (username.match(/Iamfirst/i)) { 78 | return '🥇'; 79 | } 80 | if (username.match(/biendli/i)) { 81 | return '🐝'; 82 | } 83 | if (username.match(/2pac/i)) { 84 | return '👨🏿‍🦲'; 85 | } 86 | const name = username.toUpperCase().split(' '); 87 | if (name.length === 1) { 88 | return `${name[0].charAt(0)}`; 89 | } else if (name.length > 1) { 90 | return `${name[0].charAt(0)}${name[1].charAt(0)}`; 91 | } else { 92 | return ''; 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /src/math.ts: -------------------------------------------------------------------------------- 1 | import {interpolate, spring} from 'remotion'; 2 | import {SingleMessageApiResponse} from './Message'; 3 | 4 | const DURATION_PER_CHARACTER = 0.1; 5 | export const LIKE_DURATION = 4; 6 | const ENTRANCE_TRANSITION_DURATION = 40; 7 | const END_STILL_TIME = 250; 8 | 9 | export const getMessageSegments = ( 10 | message: SingleMessageApiResponse 11 | ): string[] => { 12 | return message.message.text.split(' '); 13 | }; 14 | 15 | export const getMessageSegmentDurations = ( 16 | message: SingleMessageApiResponse 17 | ): number[] => { 18 | return getMessageSegments(message).map( 19 | (t) => t.length * DURATION_PER_CHARACTER 20 | ); 21 | }; 22 | 23 | export const getMessageDuration = ( 24 | message: SingleMessageApiResponse 25 | ): number => { 26 | return ( 27 | getMessageSegmentDurations(message).reduce((a, b) => a + b, 0) + 28 | LIKE_DURATION 29 | ); 30 | }; 31 | 32 | export const getOpacityForWord = ( 33 | message: SingleMessageApiResponse, 34 | wordIndex: number, 35 | frame: number 36 | ): number => { 37 | const charactersBefore = getMessageSegmentDurations(message) 38 | .slice(0, wordIndex) 39 | .reduce((a, b) => a + b, 0); 40 | return interpolate(frame, [charactersBefore, charactersBefore + 4], [0, 1]); 41 | }; 42 | 43 | export const getMessageDurations = ( 44 | messages: SingleMessageApiResponse[] 45 | ): number[] => messages.map((m) => getMessageDuration(m)); 46 | 47 | export const messageStart = ( 48 | messages: SingleMessageApiResponse[], 49 | index: number 50 | ): number => { 51 | const durationsBefore = getMessageDurations(messages).slice(0, index); 52 | return ( 53 | durationsBefore.reduce((a, b) => a + b, 0) + 54 | durationsBefore.length * ENTRANCE_TRANSITION_DURATION 55 | ); 56 | }; 57 | 58 | export const messageEntrance = ({ 59 | messages, 60 | index, 61 | fps, 62 | frame, 63 | }: { 64 | messages: SingleMessageApiResponse[]; 65 | index: number; 66 | fps: number; 67 | frame: number; 68 | }): number => { 69 | return spring({ 70 | fps, 71 | frame: frame - messageStart(messages, index), 72 | config: { 73 | damping: 200, 74 | }, 75 | }); 76 | }; 77 | 78 | export const getAllMessagesDurations = ( 79 | messages: SingleMessageApiResponse[] 80 | ): number => { 81 | const durations = getMessageDurations(messages); 82 | return ( 83 | durations.reduce((a, b) => a + b, 0) + 84 | (messages.length - 1) * ENTRANCE_TRANSITION_DURATION + 85 | END_STILL_TIME 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This is an example of a server that returns dynamic video. 3 | * Run `npm run server` to try it out! 4 | * If you don't want to render videos on a server, you can safely 5 | * delete this file. 6 | */ 7 | 8 | import {bundle} from '@remotion/bundler'; 9 | import { 10 | getCompositions, 11 | renderFrames, 12 | stitchFramesToVideo, 13 | } from '@remotion/renderer'; 14 | import express from 'express'; 15 | import fs from 'fs'; 16 | import os from 'os'; 17 | import path from 'path'; 18 | 19 | const app = express(); 20 | const port = 8000; 21 | const compositionId = 'HelloWorld'; 22 | 23 | const cache = new Map(); 24 | 25 | app.get('/', async (req, res) => { 26 | const sendFile = (file: string) => { 27 | fs.createReadStream(file) 28 | .pipe(res) 29 | .on('close', () => { 30 | res.end(); 31 | }); 32 | }; 33 | try { 34 | if (cache.get(JSON.stringify(req.query))) { 35 | sendFile(cache.get(JSON.stringify(req.query)) as string); 36 | return; 37 | } 38 | const bundled = await bundle(path.join(__dirname, './src/index.tsx')); 39 | const comps = await getCompositions(bundled); 40 | const video = comps.find((c) => c.id === compositionId); 41 | if (!video) { 42 | throw new Error(`No video called ${compositionId}`); 43 | } 44 | res.set('content-type', 'video/mp4'); 45 | 46 | const tmpDir = await fs.promises.mkdtemp( 47 | path.join(os.tmpdir(), 'remotion-') 48 | ); 49 | await renderFrames({ 50 | config: video, 51 | webpackBundle: bundled, 52 | onStart: () => console.log('Rendering frames...'), 53 | onFrameUpdate: (f) => { 54 | if (f % 10 === 0) { 55 | console.log(`Rendered frame ${f}`); 56 | } 57 | }, 58 | parallelism: null, 59 | outputDir: tmpDir, 60 | userProps: req.query, 61 | compositionId, 62 | imageFormat: 'jpeg', 63 | }); 64 | 65 | const finalOutput = path.join(tmpDir, 'out.mp4'); 66 | await stitchFramesToVideo({ 67 | dir: tmpDir, 68 | force: true, 69 | fps: video.fps, 70 | height: video.height, 71 | width: video.width, 72 | outputLocation: finalOutput, 73 | imageFormat: 'jpeg', 74 | }); 75 | cache.set(JSON.stringify(req.query), finalOutput); 76 | sendFile(finalOutput); 77 | console.log('Video rendered and sent!'); 78 | } catch (err) { 79 | console.error(err); 80 | res.json({ 81 | error: err, 82 | }); 83 | } 84 | }); 85 | 86 | app.listen(port); 87 | 88 | console.log( 89 | [ 90 | `The server has started on http://localhost:${port}!`, 91 | 'You can render a video by passing props as URL parameters.', 92 | '', 93 | 'If you are running Hello World, try this:', 94 | '', 95 | `http://localhost:${port}?titleText=Hello,+World!&titleColor=red`, 96 | '', 97 | ].join('\n') 98 | ); 99 | -------------------------------------------------------------------------------- /src/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import {darken} from 'polished'; 2 | import React, {useCallback, useMemo} from 'react'; 3 | import {Text, View} from 'react-native'; 4 | import {Img} from 'remotion'; 5 | import {getAvatarInitials} from './get-avatar-initials'; 6 | import {getImageUrl} from './get-image-url'; 7 | import {User} from './Message'; 8 | 9 | export const BLUE = darken(0.1, '#3498db'); 10 | export const ORANGE = '#f39c12'; 11 | export const GREEN = '#2ecc71'; 12 | export const RED = '#e74c3c'; 13 | 14 | const Colors = { 15 | backgroundTransparent: 'transparent', 16 | carrot: ORANGE, 17 | emerald: GREEN, 18 | peterRiver: BLUE, 19 | wisteria: '#8e44ad', 20 | alizarin: '#e74c3c', 21 | turquoise: '#1abc9c', 22 | midnightBlue: '#2c3e50', 23 | }; 24 | 25 | const { 26 | carrot, 27 | emerald, 28 | peterRiver, 29 | wisteria, 30 | alizarin, 31 | turquoise, 32 | midnightBlue, 33 | } = Colors; 34 | 35 | const styles = { 36 | avatarStyle: { 37 | justifyContent: 'center' as const, 38 | alignItems: 'center' as const, 39 | height: 100, 40 | width: 100, 41 | borderRadius: 50, 42 | }, 43 | noAvatar: { 44 | height: 0, 45 | width: 100, 46 | }, 47 | avatarTransparent: { 48 | backgroundColor: Colors.backgroundTransparent, 49 | }, 50 | textStyle: { 51 | color: 'white', 52 | fontSize: 50, 53 | backgroundColor: Colors.backgroundTransparent, 54 | fontWeight: '500' as const, 55 | }, 56 | }; 57 | 58 | interface GiftedAvatarProps { 59 | user: User | null; 60 | onPress?(props: any): void; 61 | } 62 | 63 | const IMGIX_DOMAIN = 'https://img.bestande.ch'; 64 | 65 | export const GiftedAvatar: React.FC = (props) => { 66 | const userName = props.user?.username || ''; 67 | 68 | const avatarName = useMemo(() => { 69 | return getAvatarInitials(userName); 70 | }, [userName]); 71 | 72 | const avatarColor = useMemo(() => { 73 | let sumChars = 0; 74 | for (let i = 0; i < userName.length; i += 1) { 75 | sumChars += userName.charCodeAt(i); 76 | } 77 | 78 | const colors = [ 79 | carrot, 80 | emerald, 81 | peterRiver, 82 | wisteria, 83 | alizarin, 84 | turquoise, 85 | midnightBlue, 86 | ]; 87 | 88 | return colors[sumChars % colors.length]; 89 | }, [userName]); 90 | 91 | const renderAvatar = useCallback((user: User) => { 92 | if (typeof user.avatar === 'string') { 93 | return ( 94 | 103 | ); 104 | } 105 | return null; 106 | }, []); 107 | 108 | if (!props.user) { 109 | return ; 110 | } 111 | if (props.user.avatar) { 112 | return renderAvatar(props.user); 113 | } 114 | 115 | return ( 116 | 117 | {avatarName} 118 | 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/Story.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from 'react'; 2 | import { 3 | AbsoluteFill, 4 | Audio, 5 | continueRender, 6 | delayRender, 7 | interpolate, 8 | measureSpring, 9 | Sequence, 10 | spring, 11 | useCurrentFrame, 12 | useVideoConfig, 13 | } from 'remotion'; 14 | import {Background} from './Background'; 15 | import {messageEntrance, messageStart} from './math'; 16 | import {Message, SingleMessageApiResponse} from './Message'; 17 | import collapse from './sounds/collapse.mp3'; 18 | import audio1 from './sounds/tab1.mp3'; 19 | import audio2 from './sounds/tab2.mp3'; 20 | import audio3 from './sounds/tab3.mp3'; 21 | 22 | console.log('hi'); 23 | 24 | export const Story: React.FC<{ 25 | messageIds: string; 26 | }> = ({messageIds}) => { 27 | const {fps, durationInFrames} = useVideoConfig(); 28 | const frame = useCurrentFrame(); 29 | const [handle] = useState(() => delayRender()); 30 | const [messages, setMessages] = useState( 31 | null 32 | ); 33 | 34 | const fetchMessages = useCallback(async () => { 35 | const messages = await Promise.all( 36 | messageIds.split(',').map(async (m) => { 37 | const response = await fetch( 38 | `https://bestande.ch/api/chat/messages/${m}` 39 | ); 40 | const json = await response.json(); 41 | return json.data as SingleMessageApiResponse; 42 | }) 43 | ); 44 | setMessages(messages); 45 | 46 | continueRender(handle); 47 | }, [handle, messageIds]); 48 | 49 | useEffect(() => { 50 | fetchMessages(); 51 | }, [fetchMessages, handle]); 52 | 53 | const fadeOut = spring({ 54 | fps, 55 | frame: 56 | frame - 57 | durationInFrames + 58 | measureSpring({ 59 | fps, 60 | config: { 61 | damping: 200, 62 | }, 63 | }), 64 | config: { 65 | damping: 200, 66 | }, 67 | from: 1, 68 | to: 0, 69 | }); 70 | 71 | if (!messages) { 72 | return null; 73 | } 74 | 75 | return ( 76 |
77 | 78 | 79 | 90 | {messages.map((m, i) => { 91 | const entrance = messageEntrance({ 92 | messages, 93 | fps, 94 | frame, 95 | index: i, 96 | }); 97 | return ( 98 | <> 99 |
109 | 114 |
115 | 119 | 121 | 122 | ); 123 | })} 124 |
125 | 126 | 128 |
129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /src/Message.tsx: -------------------------------------------------------------------------------- 1 | import {format} from 'date-fns'; 2 | import {Img, interpolate, useCurrentFrame} from 'remotion'; 3 | import styled from 'styled-components'; 4 | import {GiftedAvatar} from './Avatar'; 5 | import heart from './heart.svg'; 6 | import {getMessageDuration, getOpacityForWord, LIKE_DURATION} from './math'; 7 | 8 | export type User = { 9 | username: string; 10 | id: string; 11 | avatar: string | null; 12 | joined: number; 13 | lastUsernameChange: number; 14 | admin: boolean; 15 | verified?: boolean; 16 | }; 17 | 18 | export type ChatMessage = { 19 | _id: string; 20 | text: string; 21 | createdAt: number; 22 | uni_identifier: string; 23 | university: 'UZH'; 24 | userId: string; 25 | system?: boolean; 26 | likes?: string[]; 27 | quotes?: string; 28 | }; 29 | 30 | export type SingleMessageApiResponse = { 31 | message: ChatMessage; 32 | user: User; 33 | usersWhoLiked: User[]; 34 | }; 35 | 36 | const Container = styled.div` 37 | font-size: 45px; 38 | line-height: 1.3; 39 | margin-left: 100px; 40 | margin-right: 100px; 41 | font-family: Arial, Helvetica, sans-serif; 42 | margin-bottom: 50px; 43 | margin-top: 50px; 44 | `; 45 | 46 | const Spacer = styled.div` 47 | width: 30px; 48 | `; 49 | 50 | const Username = styled.div` 51 | font-weight: bold; 52 | font-size: 0.8em; 53 | margin-bottom: 9px; 54 | `; 55 | 56 | const Time = styled.span` 57 | color: gray; 58 | font-size: 0.9em; 59 | font-weight: normal; 60 | `; 61 | 62 | const Heart = styled(Img)` 63 | width: ${342 * 0.09}px; 64 | height: ${315 * 0.09}px; 65 | `; 66 | 67 | const LikesLabel = styled.div` 68 | color: rgba(0, 0, 0, 0.2); 69 | font-size: 0.9em; 70 | `; 71 | 72 | export const Message: React.FC<{ 73 | message: SingleMessageApiResponse; 74 | delay: number; 75 | }> = ({delay, message}) => { 76 | const frame = useCurrentFrame(); 77 | const likeArray = message.usersWhoLiked.map((m) => m.username); 78 | const words = message.message.text.split(' '); 79 | const wordOpacity = (i: number) => 80 | getOpacityForWord(message, i, frame - delay); 81 | const totalDuration = getMessageDuration(message); 82 | const likesOpacity = interpolate( 83 | frame - delay, 84 | [totalDuration - LIKE_DURATION, totalDuration], 85 | [0, 1] 86 | ); 87 | return ( 88 | 89 |
90 | 91 | 92 |
93 | 94 | {message.user.username}{' '} 95 | 96 | 97 |
98 | {words.map((w, i) => { 99 | return ( 100 | 101 | {w}{' '} 102 | 103 | ); 104 | })} 105 |
106 |
115 | {likeArray.length > 0 ? ( 116 | <> 117 | 118 |
119 | 120 | {likeArray.length > 2 121 | ? likeArray[0] + 122 | ' + ' + 123 | String(likeArray.length - 1) + 124 | ' ' + 125 | 'others' 126 | : likeArray.join(', ')} 127 | 128 | 129 | ) : null} 130 |
131 |
132 |
133 | 134 | ); 135 | }; 136 | --------------------------------------------------------------------------------