├── .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 |
21 |
--------------------------------------------------------------------------------
/src/Background.tsx:
--------------------------------------------------------------------------------
1 | export const Background: React.FC = () => {
2 | return (
3 |
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 |
120 |
121 | >
122 | );
123 | })}
124 |
125 |
126 |
127 |
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 |
--------------------------------------------------------------------------------