├── .dockerignore ├── .env.sample ├── .eslintrc.js ├── .github └── workflows │ └── tweet-daily-video.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── package.json ├── postcss.config.js ├── prettier.config.js ├── remotion.config.ts ├── scripts ├── fetchProductHunt.mjs └── postTweet.mjs ├── server.tsx ├── src ├── ProductHuntToday.tsx ├── Video.tsx ├── components │ ├── BaseBackground.tsx │ ├── ContentWrapper.tsx │ ├── Image.tsx │ ├── ImagesCarousel.tsx │ ├── ProductDetail.tsx │ ├── ProductDetailProduct.tsx │ ├── ProductList.tsx │ ├── ProductListProduct.tsx │ └── Rank.tsx ├── hooks │ └── useProductHuntData.ts ├── index.tsx └── style.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_PRODUCT_HUNT_API_KEY= 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eason'], 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/tweet-daily-video.yml: -------------------------------------------------------------------------------- 1 | name: Tweet Daily Video 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "5 8 * * *" # Daily (00:05 every day in Pacific Timezone) 7 | 8 | env: 9 | REACT_APP_PRODUCT_HUNT_API_KEY: ${{ secrets.REACT_APP_PRODUCT_HUNT_API_KEY }} 10 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 11 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 12 | TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 13 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 14 | 15 | jobs: 16 | render: 17 | name: Tweet Daily Video 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@main 21 | - uses: actions/setup-node@main 22 | - run: sudo apt update 23 | - run: sudo apt install ffmpeg 24 | - run: mkdir data 25 | - run: yarn install 26 | - run: yarn fetch 27 | - run: yarn build 28 | - run: yarn post-tweet 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: video.mp4 32 | path: out/video.mp4 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .env 5 | 6 | # Ignore the output video from Git but not videos you import into src/. 7 | out 8 | 9 | # Product Hunt API script result json 10 | today.json 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.codeActionsOnSave": { 5 | "source.organizeImports": false, 6 | "source.fixAll": true 7 | }, 8 | "typescript.enablePromptUseWorkspaceTsdk": true 9 | } 10 | -------------------------------------------------------------------------------- /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 | font-noto-emoji 17 | 18 | # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. 19 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ 20 | PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 21 | 22 | COPY package*.json ./ 23 | COPY tsconfig.json ./ 24 | COPY src src 25 | COPY *.ts . 26 | COPY *.tsx . 27 | 28 | RUN npm i 29 | 30 | # Add user so we don't need --no-sandbox. 31 | RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \ 32 | && mkdir -p /home/pptruser/Downloads /app \ 33 | && chown -R pptruser:pptruser /home/pptruser \ 34 | && chown -R pptruser:pptruser /app 35 | # Run everything after as non-privileged user. 36 | USER pptruser 37 | 38 | EXPOSE 8000 39 | 40 | CMD ["npm", "run", "server"] 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Product Hunt Today 2 | 3 | Product Hunt Today Twitter account 4 | 5 | A Twitter bot [@ProductHunToday](https://twitter.com/ProductHunToday) that tweets trending [Product Hunt](https://www.producthunt.com/) products every day, in short video! 6 | 7 | ## Tech stack 8 | 9 | - [Remotion](https://www.remotion.dev/): Generate short video in React! 10 | - [Product Hunt API 2.0 (GraphQL)](https://api.producthunt.com/v2/docs): Fetch trending products 11 | - [Twitter API](https://developer.twitter.com/en/docs/twitter-api): Tweet post thread with video 12 | - [Tailwind CSS](https://tailwindcss.com/): UI of video content 13 | - [Github Actions](https://github.com/features/actions): Run scheduled job everyday (fetch data from Product Hunt -> generate video -> post Twitter) 14 | - [google/zx](https://github.com/google/zx): Write modern shell script in JavaScript 15 | - Node.js: v16.14.0 16 | 17 | ## Sample tweet 18 | 19 | https://twitter.com/ProductHunToday/status/1506186218714849287 20 | 21 | Twitter post from Product Hunt Today 22 | 23 | ## Get started 24 | 25 | ### Install dependencies 26 | 27 | ```console 28 | yarn install 29 | ``` 30 | 31 | ### Setup environment variables 32 | 33 | Create `.env` file, with your [Product Hunt](https://api.producthunt.com/v2/docs) & [Twitter](https://developer.twitter.com/en/docs/twitter-api) API key 34 | 35 | ```env 36 | REACT_APP_PRODUCT_HUNT_API_KEY="" 37 | TWITTER_CONSUMER_KEY="" 38 | TWITTER_CONSUMER_SECRET="" 39 | TWITTER_ACCESS_TOKEN_KEY="" 40 | TWITTER_ACCESS_TOKEN_SECRET="" 41 | ``` 42 | 43 | ### Fetch products 44 | 45 | This will call Product Hunt API, and store result in `/data/today.json` 46 | 47 | ```console 48 | yarn fetch 49 | ``` 50 | 51 | ### Start preview 52 | 53 | This will open browser to preview video 54 | 55 | ```console 56 | yarn start 57 | ``` 58 | 59 | ### Render video 60 | 61 | This will store generated video in `/out/video.mp4` 62 | 63 | ```console 64 | yarn build 65 | ``` 66 | 67 | ### Post to Twitter 68 | 69 | ```console 70 | yarn post-tweet 71 | ``` 72 | 73 | ## Contribute 74 | 75 | PRs are welcome! 76 | 77 | Feel free to DM me on Twitter [@EasonChang_me](https://twitter.com/EasonChang_me) if any suggestions 78 | 79 | ## Support 80 | 81 | Support me in creating more awesome projects! 82 | 83 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/easonchang) 84 | -------------------------------------------------------------------------------- /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 ProductHuntToday out/video.mp4 --timeout 1000000", 8 | "upgrade": "remotion upgrade", 9 | "server": "ts-node server.tsx", 10 | "test": "eslint src --ext ts,tsx,js,jsx && tsc", 11 | "fetch": "zx scripts/fetchProductHunt.mjs", 12 | "post-tweet": "zx scripts/postTweet.mjs", 13 | "lint": "eslint src --ext ts,tsx,js,jsx", 14 | "lint:fix": "eslint src --ext ts,tsx,js,jsx --fix" 15 | }, 16 | "repository": {}, 17 | "license": "UNLICENSED", 18 | "dependencies": { 19 | "@remotion/bundler": "^2.6.11", 20 | "@remotion/cli": "^2.6.11", 21 | "@remotion/gif": "^2.6.11", 22 | "@remotion/renderer": "^2.6.11", 23 | "@tailwindcss/aspect-ratio": "^0.4.0", 24 | "@tailwindcss/line-clamp": "^0.3.1", 25 | "autoprefixer": "^10.4.2", 26 | "date-fns": "^2.28.0", 27 | "date-fns-tz": "^1.3.0", 28 | "dotenv": "^16.0.0", 29 | "eslint": "^8.11.0", 30 | "express": "^4.17.1", 31 | "postcss": "^8.4.8", 32 | "postcss-loader": "^6.2.1", 33 | "postcss-preset-env": "^7.4.2", 34 | "prettier": "^2.5.1", 35 | "react": "^17.0.2", 36 | "react-dom": "^17.0.2", 37 | "remotion": "^2.6.11", 38 | "tailwindcss": "2", 39 | "ts-node": "^9.1.1", 40 | "twitter-api-v2": "^1.11.1", 41 | "typescript": "^4.6.2", 42 | "zx": "^5.2.0" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.17.5", 46 | "@babel/eslint-parser": "^7.17.0", 47 | "@babel/preset-react": "^7.16.7", 48 | "@types/express": "^4.17.9", 49 | "@types/node": "^17.0.21", 50 | "@types/react": "^17.0.0", 51 | "@types/web": "^0.0.46", 52 | "@typescript-eslint/eslint-plugin": "^5.14.0", 53 | "@typescript-eslint/parser": "^5.14.0", 54 | "eslint-config-eason": "^0.0.6", 55 | "eslint-config-prettier": "^8.5.0", 56 | "eslint-plugin-html": "^6.2.0", 57 | "eslint-plugin-import": "^2.25.4", 58 | "eslint-plugin-jsx-a11y": "^6.5.1", 59 | "eslint-plugin-prettier": "^4.0.0", 60 | "eslint-plugin-react": "^7.29.3", 61 | "eslint-plugin-react-hooks": "^4.3.0", 62 | "eslint-plugin-simple-import-sort": "^7.0.0", 63 | "prettier-plugin-tailwindcss": "^0.1.8" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('prettier-plugin-tailwindcss')], 3 | } 4 | -------------------------------------------------------------------------------- /remotion.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'remotion' 2 | 3 | Config.Rendering.setImageFormat('jpeg') 4 | Config.Output.setOverwriteOutput(true) 5 | 6 | // https://www.remotion.dev/docs/webpack#enable-tailwindcss-support 7 | Config.Bundling.overrideWebpackConfig((currentConfiguration) => { 8 | return { 9 | ...currentConfiguration, 10 | module: { 11 | ...currentConfiguration.module, 12 | rules: [ 13 | ...(currentConfiguration.module?.rules 14 | ? currentConfiguration.module.rules 15 | : [] 16 | ).filter((rule) => { 17 | if (rule === '...') { 18 | return false 19 | } 20 | if (rule.test?.toString().includes('.css')) { 21 | return false 22 | } 23 | return true 24 | }), 25 | { 26 | test: /\.css$/i, 27 | use: [ 28 | 'style-loader', 29 | 'css-loader', 30 | { 31 | loader: 'postcss-loader', 32 | options: { 33 | postcssOptions: { 34 | plugins: [ 35 | 'postcss-preset-env', 36 | 'tailwindcss', 37 | 'autoprefixer', 38 | ], 39 | }, 40 | }, 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /scripts/fetchProductHunt.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | // To run this script to fetch today featured products: 4 | // yarn fetch 5 | // or specify a date: 6 | // yarn fetch 2020/01/01 7 | 8 | // eslint-disable-next-line import/no-unresolved 9 | import 'zx/globals' 10 | import 'dotenv/config' 11 | 12 | import prettier from 'prettier' 13 | 14 | /* eslint-disable no-undef */ 15 | $.verbose = false 16 | 17 | const selectThreeImages = (images) => { 18 | if (images.length < 1) { 19 | return [] 20 | } 21 | if (images.length === 1) { 22 | return [images[0], images[0], images[0]] 23 | } 24 | if (images.length === 2) { 25 | return [images[0], images[1], images[0]] 26 | } 27 | return [images[0], images[1], images[2]] 28 | } 29 | 30 | // For product detail carousel images, reduce image file size & video build time 31 | const constructImgixParameter = (src) => { 32 | const url = new URL(src) 33 | url.searchParams.set('w', '624') 34 | url.searchParams.set('h', '351') 35 | url.searchParams.set('fit', 'clip') 36 | return url.toString() 37 | } 38 | 39 | const dateArg = 40 | argv['_']?.[1] || new Date().setUTCDate(new Date().getUTCDate() - 1) 41 | const postedAfterDate = new Date(dateArg) 42 | postedAfterDate.setUTCHours(-8, 0, 0, 0) // Pacific Time (-8) 43 | const postedBeforeDate = new Date(postedAfterDate) 44 | postedBeforeDate.setUTCDate(postedAfterDate.getUTCDate() + 1) 45 | 46 | const res = await fetch('https://api.producthunt.com/v2/api/graphql', { 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | Accept: 'application/json', 51 | Authorization: 'Bearer ' + process.env.REACT_APP_PRODUCT_HUNT_API_KEY, 52 | }, 53 | body: JSON.stringify({ 54 | query: ` 55 | { 56 | posts(first: 5, order: RANKING, featured: true, postedBefore: "${postedBeforeDate.toISOString()}", postedAfter: "${postedAfterDate.toISOString()}") { 57 | edges { 58 | node { 59 | name 60 | slug 61 | tagline 62 | description 63 | thumbnail { 64 | url 65 | } 66 | url 67 | votesCount 68 | commentsCount 69 | user { 70 | name 71 | profileImage 72 | } 73 | media { 74 | type 75 | url 76 | videoUrl 77 | } 78 | topics { 79 | edges { 80 | node { 81 | name 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | `, 90 | }), 91 | }) 92 | const json = await res.json() 93 | const products = json.data.posts.edges 94 | .map((edge) => edge.node) 95 | .map((product, index) => { 96 | return { 97 | name: product.name, 98 | tagline: product.tagline, 99 | description: product.description, 100 | url: product.url.split('?')[0], 101 | rank: index + 1, 102 | thumbnail: product.thumbnail?.url, 103 | votesCount: product.votesCount, 104 | user: { 105 | name: product.user?.name, 106 | profileImage: product.user?.profileImage, 107 | }, 108 | images: selectThreeImages( 109 | product.media 110 | ?.filter((media) => media.type === 'image') 111 | .map((media) => constructImgixParameter(media.url)) 112 | ), 113 | topics: product.topics?.edges?.map((edge) => edge.node.name), 114 | } 115 | }) 116 | 117 | const result = { date: postedBeforeDate.toISOString(), products: products } 118 | console.log(result) 119 | 120 | fs.writeFileSync( 121 | path.resolve(__dirname, '../data/today.json'), 122 | prettier.format(JSON.stringify(result), { parser: 'json' }) 123 | ) 124 | -------------------------------------------------------------------------------- /scripts/postTweet.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | // eslint-disable-next-line import/no-unresolved 4 | import 'zx/globals' 5 | 6 | require('dotenv').config() 7 | 8 | const path = require('path') 9 | const { TwitterApi } = require('twitter-api-v2') 10 | const { formatInTimeZone } = require('date-fns-tz') 11 | 12 | const data = require('../data/today.json') 13 | 14 | const keycap = '\uFE0F\u20E3' 15 | const RANK_TO_EMOJI = [ 16 | '0' + keycap, 17 | '1' + keycap, 18 | '2' + keycap, 19 | '3' + keycap, 20 | '4' + keycap, 21 | '5' + keycap, 22 | '6' + keycap, 23 | '7' + keycap, 24 | '8' + keycap, 25 | '9' + keycap, 26 | ] 27 | function rankToNumberEmoji(rank) { 28 | if (!Number.isInteger(rank) || rank > 5 || rank < 1) return '' 29 | 30 | return RANK_TO_EMOJI[rank] 31 | } 32 | 33 | function rankToMedalEmoji(rank) { 34 | if (!Number.isInteger(rank) || rank > 3 || rank < 1) return '' 35 | 36 | return ['', '🥇', '🥈', '🥉'][rank] 37 | } 38 | 39 | const composeProduct = (product) => { 40 | return `${product.rank}. ${product.name} 🔼 ${product.votesCount}` 41 | } 42 | 43 | // ============================================================================= 44 | 45 | const _composeMainContentLong = () => { 46 | const { products, date } = data 47 | const formattedDate = formatInTimeZone( 48 | new Date(date), 49 | 'America/Los_Angeles', 50 | 'MMMM d, yyyy' 51 | ) 52 | 53 | const formattedProducts = products 54 | .map((product) => composeProduct(product)) 55 | .join('\n') 56 | 57 | let content = `🔥 Top 5 on Product Hunt yesterday 58 | 📅 ${formattedDate} #ProductHunt 59 | 60 | ${formattedProducts} 61 | 62 | 🧵 Detail & links in the thread 👇 63 | ` 64 | 65 | return content 66 | } 67 | 68 | const _composeMainContentShort = () => { 69 | const { products, date } = data 70 | const formattedDate = formatInTimeZone( 71 | new Date(date), 72 | 'America/Los_Angeles', 73 | 'MMMM d, yyyy' 74 | ) 75 | 76 | const formattedProducts = products 77 | .map((product) => composeProduct(product)) 78 | .join('\n') 79 | 80 | let content = `🔥 Top 5 on Product Hunt yesterday 81 | 📅 ${formattedDate} #ProductHunt 82 | 83 | ${formattedProducts}` 84 | 85 | return content 86 | } 87 | 88 | const composeMainContent = () => { 89 | if (_composeMainContentLong().length > 280) { 90 | return _composeMainContentShort() 91 | } 92 | return _composeMainContentLong() 93 | } 94 | 95 | // ============================================================================= 96 | 97 | const _composeDetailContentLong = (product) => { 98 | const { name, description, url, rank, votesCount } = product 99 | return `${rankToNumberEmoji(rank)} ${name} ${rankToMedalEmoji(rank)} 100 | 🔼 ${votesCount} 101 | 102 | ${description} 103 | 104 | ${url}` 105 | } 106 | 107 | const _composeDetailContentShort = (product) => { 108 | const { name, tagline, url, rank, votesCount } = product 109 | return `${rankToNumberEmoji(rank)} ${name} ${rankToMedalEmoji(rank)} 110 | 🔼 ${votesCount} 111 | 112 | ${tagline} 113 | 114 | ${url}` 115 | } 116 | 117 | const composeDetailContent = (product) => { 118 | if (_composeDetailContentLong(product).length > 280) { 119 | return _composeDetailContentShort(product) 120 | } 121 | return _composeDetailContentLong(product) 122 | } 123 | 124 | // ============================================================================= 125 | 126 | async function run() { 127 | const { products } = data 128 | 129 | const client = new TwitterApi({ 130 | appKey: process.env.TWITTER_CONSUMER_KEY, 131 | appSecret: process.env.TWITTER_CONSUMER_SECRET, 132 | accessToken: process.env.TWITTER_ACCESS_TOKEN_KEY, 133 | accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET, 134 | }) 135 | 136 | const mediaIdVideo = await client.v1.uploadMedia( 137 | path.resolve(__dirname, '../out/video.mp4'), 138 | { type: 'longmp4' } 139 | ) 140 | 141 | await client.v2.tweetThread([ 142 | { 143 | text: composeMainContent(), 144 | media: { media_ids: [mediaIdVideo] }, 145 | }, 146 | ...products.map((product) => ({ 147 | text: composeDetailContent(product), 148 | })), 149 | { 150 | text: '👉 Follow @ProductHunToday bring #ProductHunt to your feed. Never missing trending hunts again', 151 | }, 152 | ]) 153 | } 154 | 155 | run() 156 | -------------------------------------------------------------------------------- /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 = process.env.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, {inputProps: req.query}); 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 | const {assetsInfo} = 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 | inputProps: 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 | assetsInfo, 75 | }); 76 | cache.set(JSON.stringify(req.query), finalOutput); 77 | sendFile(finalOutput); 78 | console.log('Video rendered and sent!'); 79 | } catch (err) { 80 | console.error(err); 81 | res.json({ 82 | error: err, 83 | }); 84 | } 85 | }); 86 | 87 | app.listen(port); 88 | 89 | console.log( 90 | [ 91 | `The server has started on http://localhost:${port}!`, 92 | 'You can render a video by passing props as URL parameters.', 93 | '', 94 | 'If you are running Hello World, try this:', 95 | '', 96 | `http://localhost:${port}?titleText=Hello,+World!&titleColor=red`, 97 | '', 98 | ].join('\n') 99 | ); 100 | -------------------------------------------------------------------------------- /src/ProductHuntToday.tsx: -------------------------------------------------------------------------------- 1 | import { Sequence, useVideoConfig } from 'remotion' 2 | 3 | import { BaseBackground } from './components/BaseBackground' 4 | import { ContentWrapper } from './components/ContentWrapper' 5 | import { ProductDetail } from './components/ProductDetail' 6 | import { ProductList } from './components/ProductList' 7 | import useProductHuntData from './hooks/useProductHuntData' 8 | 9 | export const ProductHuntToday = () => { 10 | const videoConfig = useVideoConfig() 11 | const { products, date } = useProductHuntData() 12 | 13 | return ( 14 |
15 | {/* Background */} 16 | 17 | 18 | 19 | 20 | {/* ProductList */} 21 | 22 | 23 | 24 | 25 | 26 | 27 | {/* ProductDetails */} 28 | {products.map((product, index) => ( 29 | 35 | 36 | 37 | 38 | 39 | ))} 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/Video.tsx: -------------------------------------------------------------------------------- 1 | import { Composition } from 'remotion' 2 | 3 | import { ProductHuntToday } from './ProductHuntToday' 4 | 5 | export const RemotionVideo = () => { 6 | return ( 7 | <> 8 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/BaseBackground.tsx: -------------------------------------------------------------------------------- 1 | import { formatInTimeZone } from 'date-fns-tz' 2 | 3 | export const BaseBackground = ({ date }) => { 4 | return ( 5 |
6 |
7 |

11 | Top 5 on Product Hunt yesterday 12 |

13 |

14 | {formatInTimeZone( 15 | new Date(date), 16 | 'America/Los_Angeles', 17 | 'MMMM d, yyyy' 18 | )} 19 |

20 |
21 | 22 | @ProductHunToday 23 | 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | export const ContentWrapper = ({ children }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import { Gif } from '@remotion/gif' 2 | import { Img } from 'remotion' 3 | 4 | export const Image = ({ src, ...other }) => { 5 | if (src.includes('.gif')) { 6 | return 7 | } 8 | 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ImagesCarousel.tsx: -------------------------------------------------------------------------------- 1 | import { spring, useCurrentFrame, useVideoConfig } from 'remotion' 2 | 3 | import { Image } from './Image' 4 | 5 | export const ImagesCarousel = ({ images }) => { 6 | const frame = useCurrentFrame() 7 | const { fps } = useVideoConfig() 8 | 9 | const springConfig = { 10 | mass: 0.3, 11 | stiffness: 150, 12 | } 13 | 14 | const translate1 = spring({ 15 | fps, 16 | from: 0, 17 | to: 100, 18 | frame: frame - 50, 19 | config: springConfig, 20 | }) 21 | 22 | const translate2 = spring({ 23 | fps, 24 | from: 100, 25 | to: 200, 26 | frame: frame - 110, 27 | config: springConfig, 28 | }) 29 | 30 | return ( 31 |
32 |
38 | {images.map((image) => ( 39 | 44 | ))} 45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ProductDetail.tsx: -------------------------------------------------------------------------------- 1 | import { interpolate, useCurrentFrame, useVideoConfig } from 'remotion' 2 | 3 | import { ProductDetailProduct } from './ProductDetailProduct' 4 | 5 | export const ProductDetail = ({ product }) => { 6 | const frame = useCurrentFrame() 7 | const videoConfig = useVideoConfig() 8 | 9 | const opacity = interpolate( 10 | frame, 11 | [videoConfig.durationInFrames - 8, videoConfig.durationInFrames], 12 | [1, 0], 13 | { 14 | extrapolateLeft: 'clamp', 15 | extrapolateRight: 'clamp', 16 | } 17 | ) 18 | 19 | return ( 20 |
24 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ProductDetailProduct.tsx: -------------------------------------------------------------------------------- 1 | import { interpolate, useCurrentFrame } from 'remotion' 2 | 3 | import { Image } from './Image' 4 | import { ImagesCarousel } from './ImagesCarousel' 5 | import { Rank } from './Rank' 6 | 7 | export const ProductDetailProduct = ({ product }) => { 8 | const { rank, thumbnail, name, topics, description, images, votesCount } = 9 | product 10 | const frame = useCurrentFrame() 11 | 12 | const opacity = interpolate(frame, [0, 8], [0, 1], { 13 | extrapolateLeft: 'clamp', 14 | extrapolateRight: 'clamp', 15 | }) 16 | 17 | const translate = interpolate(frame, [0, 8], [20, 0], { 18 | extrapolateLeft: 'clamp', 19 | extrapolateRight: 'clamp', 20 | }) 21 | 22 | return ( 23 |
24 |
25 |
26 | 27 |
28 |

29 | {name} 30 |

31 |
32 | {topics.map((topic) => ( 33 | 37 | {topic} 38 | 39 | ))} 40 |
41 |
42 |
43 | 44 |
45 | 46 |
47 |

48 |

{votesCount}

49 |
50 |
51 |
52 | 53 |
54 |

55 | {description} 56 |

57 |
58 | 59 | 60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/components/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import { interpolate, useCurrentFrame, useVideoConfig } from 'remotion' 2 | 3 | import { ProductListProduct } from './ProductListProduct' 4 | 5 | export const ProductList = ({ products }) => { 6 | const frame = useCurrentFrame() 7 | const videoConfig = useVideoConfig() 8 | 9 | const opacity = interpolate( 10 | frame, 11 | [videoConfig.durationInFrames - 8, videoConfig.durationInFrames], 12 | [1, 0], 13 | { 14 | extrapolateLeft: 'clamp', 15 | extrapolateRight: 'clamp', 16 | } 17 | ) 18 | 19 | return ( 20 |
24 | {products.map((product, index) => ( 25 | 30 | ))} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ProductListProduct.tsx: -------------------------------------------------------------------------------- 1 | import { interpolate, useCurrentFrame } from 'remotion' 2 | 3 | import { Image } from './Image' 4 | import { Rank } from './Rank' 5 | 6 | export const ProductListProduct = ({ product, transitionStart }) => { 7 | const frame = useCurrentFrame() 8 | 9 | const opacity = 10 | frame < 3 11 | ? 1 12 | : interpolate(frame, [transitionStart, transitionStart + 10], [0, 1], { 13 | extrapolateLeft: 'clamp', 14 | extrapolateRight: 'clamp', 15 | }) 16 | 17 | const translate = 18 | frame < 3 19 | ? 0 20 | : interpolate(frame, [transitionStart, transitionStart + 10], [20, 0], { 21 | extrapolateLeft: 'clamp', 22 | extrapolateRight: 'clamp', 23 | }) 24 | 25 | return ( 26 |
30 |
31 | 32 | 33 | 34 |
35 |

36 | {product.name} 37 |

38 |

39 | {product.tagline} 40 |

41 |
42 |
43 |
44 |

45 |

{product.votesCount}

46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Rank.tsx: -------------------------------------------------------------------------------- 1 | const RANK_TO_COLOR = { 2 | 1: { 3 | color: '#fff1b5', 4 | backgroundColor: '#e3c000', 5 | }, 6 | 2: { 7 | color: '#efefef', 8 | backgroundColor: '#b6b6b6', 9 | }, 10 | 3: { 11 | color: '#ffc179', 12 | backgroundColor: '#bd6e3c', 13 | }, 14 | 4: { 15 | color: '#ffc179', 16 | backgroundColor: '#bd6e3c', 17 | }, 18 | 5: { 19 | color: '#ffc179', 20 | backgroundColor: '#bd6e3c', 21 | }, 22 | } 23 | 24 | export const Rank = ({ rank }) => { 25 | if (!Number.isInteger(rank) || rank > 5 || rank < 1) return null 26 | 27 | return ( 28 |
32 |

{rank}

33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useProductHuntData.ts: -------------------------------------------------------------------------------- 1 | import data from '../../data/today.json' 2 | 3 | export default function useProductHuntData() { 4 | const { products, date } = data 5 | return { products, date } 6 | } 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | import { registerRoot } from 'remotion' 4 | 5 | import { RemotionVideo } from './Video' 6 | 7 | registerRoot(RemotionVideo) 8 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); */ 2 | @import url("https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"); 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | @layer base { 9 | html { 10 | font-family: "Roboto", sans-serif; 11 | } 12 | } 13 | 14 | @layer components { 15 | .overflow-fadeout-right { 16 | -webkit-mask-image: linear-gradient(270deg, transparent 16px, white 66px); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | purge: ['./src/**/*.{js,jsx,ts,tsx}'], 4 | darkMode: false, // or 'media' or 'class' 5 | theme: { 6 | extend: { 7 | textColor: { 8 | primary: '#da5630', 9 | }, 10 | }, 11 | }, 12 | variants: { 13 | extend: {}, 14 | }, 15 | plugins: [ 16 | require('@tailwindcss/line-clamp'), 17 | require('@tailwindcss/aspect-ratio'), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "jsx": "react-jsx", 6 | "outDir": "./dist", 7 | "noEmit": true, 8 | "lib": ["es2015"], 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------