├── .nvmrc ├── .npmrc ├── .eslintignore ├── src ├── index.ts ├── models │ ├── Segments.ts │ └── InterfaceJsonContent.ts ├── types.d.ts ├── config │ ├── format.ts │ ├── secrets.ts │ └── defaultPaths.ts ├── hooks │ ├── init │ │ ├── loadSecrets.ts │ │ └── createTmpSymLink.ts │ └── prerun │ │ └── copyLastContentToPublic.ts ├── commands │ ├── clean.ts │ ├── info.ts │ └── content.ts ├── utils │ ├── randomNumbersBetween.ts │ ├── log.ts │ ├── getFiles.ts │ └── CliProgress │ │ ├── terminal.ts │ │ ├── eta.ts │ │ └── formatter.ts └── services │ ├── BundleVideoService.ts │ ├── ExportDataService.ts │ ├── ValidatesContentService.ts │ ├── CleanTmpService.ts │ ├── index.ts │ ├── GetContentService.ts │ ├── CreateThumnailService.ts │ ├── CreateContentTemplateService.ts │ └── GenerateTitleService.ts ├── assets ├── click.mp3 ├── Avatar.png ├── TechLogos.png ├── transition.mp3 ├── LogoPodcast.png ├── Nunito-Bold.woff ├── Nunito-Light.woff ├── Nunito-Regular.woff ├── Nunito-ExtraLight.woff ├── Nunito-SemiBold.woff ├── ProductSans-Black.woff ├── ProductSans-Thin.woff ├── ProductSans-Regular.woff ├── CourierPrime-Regular.woff ├── mouse.svg └── fonts.css ├── prettier.config.js ├── video └── src │ ├── index.tsx │ ├── utils │ ├── measureWord.ts │ └── measureParagraph.ts │ ├── Wrappers │ └── index.tsx │ ├── Podcast │ ├── Arc.tsx │ ├── Logo.tsx │ ├── Transition.tsx │ └── AudioWaveform.tsx │ └── Video.tsx ├── bin ├── run └── dev ├── remotion.config.ts ├── .gitignore ├── .editorconfig ├── .env.local ├── .vscode └── launch.json ├── tsconfig.json ├── .github └── workflows │ └── auto-merge-pullrequest.yml ├── LICENSE ├── content ├── 1662561665-07092022.json ├── 1668522643-15112022.json ├── 1667400046-02112022.json ├── 1711722369-29032024.json ├── 1672756075-03012023.json ├── 1686234418-08062023.json ├── 1682087196-21042023.json ├── 1694096888-07092023.json ├── 1672669616-02012023.json ├── 1672842575-04012023.json ├── 1680877550-07042023.json ├── 1697120936-12102023.json ├── 1682951194-01052023.json ├── 1677248947-24022023.json ├── 1677594624-28022023.json ├── 1674052085-18012023.json ├── 1672324003-29122022.json ├── 1676903444-20022023.json ├── 1674829665-27012023.json ├── 1674224901-20012023.json ├── 1677681026-01032023.json ├── 1674743252-26012023.json ├── 1677508221-27022023.json ├── 1676384971-14022023.json ├── 1676471371-15022023.json ├── 1676989750-21022023.json ├── 1675952966-09022023.json ├── 1671460123-19122022.json ├── 1666968019-28102022.json ├── 1665586272-12102022.json ├── 1671719287-22122022.json ├── 1635864718-02112021.json ├── 1676644136-17022023.json ├── 1676298566-13022023.json ├── 1677853766-03032023.json ├── 1667572270-04112022.json ├── 1675261701-01022023.json ├── 1676039352-10022023.json ├── 1671114533-15122022.json ├── 1673534337-12012023.json ├── 1669300124-24112022.json ├── 1675348095-02022023.json ├── 1674656867-25012023.json ├── 1670336911-06122022.json ├── 1677162560-23022023.json ├── 1676557744-16022023.json ├── 1666623040-24102022.json ├── 1669645768-28112022.json ├── 1674138636-19012023.json ├── 1671632862-21122022.json ├── 1704205696-02012024.json ├── 1674570458-24012023.json ├── 1670855326-12122022.json ├── 1673965672-17012023.json ├── 1675693699-06022023.json ├── 1636987947-15112021.json ├── 1677076158-22022023.json └── 1670423289-07122022.json ├── .eslintrc ├── .eslintrc.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/core'; 2 | -------------------------------------------------------------------------------- /assets/click.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/click.mp3 -------------------------------------------------------------------------------- /assets/Avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Avatar.png -------------------------------------------------------------------------------- /assets/TechLogos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/TechLogos.png -------------------------------------------------------------------------------- /assets/transition.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/transition.mp3 -------------------------------------------------------------------------------- /assets/LogoPodcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/LogoPodcast.png -------------------------------------------------------------------------------- /assets/Nunito-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Nunito-Bold.woff -------------------------------------------------------------------------------- /assets/Nunito-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Nunito-Light.woff -------------------------------------------------------------------------------- /assets/Nunito-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Nunito-Regular.woff -------------------------------------------------------------------------------- /assets/Nunito-ExtraLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Nunito-ExtraLight.woff -------------------------------------------------------------------------------- /assets/Nunito-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/Nunito-SemiBold.woff -------------------------------------------------------------------------------- /assets/ProductSans-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/ProductSans-Black.woff -------------------------------------------------------------------------------- /assets/ProductSans-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/ProductSans-Thin.woff -------------------------------------------------------------------------------- /assets/ProductSans-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/ProductSans-Regular.woff -------------------------------------------------------------------------------- /src/models/Segments.ts: -------------------------------------------------------------------------------- 1 | export default interface Segment { 2 | start: number, 3 | end: number, 4 | word: string, 5 | } -------------------------------------------------------------------------------- /assets/CourierPrime-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelippeChemello/podcast-maker/HEAD/assets/CourierPrime-Regular.woff -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /video/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {registerRoot} from 'remotion'; 2 | import {RemotionVideo} from './Video'; 3 | 4 | registerRoot(RemotionVideo); 5 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type CreateConfig = { 2 | filename?: string; 3 | needTTS?: boolean; 4 | upload?: boolean; 5 | onlyUpload?: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) 6 | -------------------------------------------------------------------------------- /remotion.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@remotion/cli/config'; 2 | 3 | Config.setCodec('h264'); 4 | Config.setImageSequence(false); 5 | Config.setVideoImageFormat('jpeg'); -------------------------------------------------------------------------------- /src/config/format.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | portrait: { width: 1080, height: 1920 }, 3 | landscape: { width: 1920, height: 1080 }, 4 | square: { height: 1200, width: 1250 }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/hooks/init/loadSecrets.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '@oclif/core'; 2 | 3 | import { loadSecrets } from '../../config/secrets'; 4 | 5 | const hook: Hook<'init'> = async function (opts) { 6 | await loadSecrets(); 7 | }; 8 | 9 | export default hook; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vercel 4 | yarn-error.log 5 | public/* 6 | !public/.gitkeep 7 | !public/example* 8 | coverage 9 | .env 10 | .env.prod 11 | .env.dev 12 | access_token.json 13 | google-youtube.json 14 | google-refresh.ts 15 | token.json 16 | video/tmp 17 | public -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | (async () => { 4 | const oclif = require('@oclif/core') 5 | 6 | await import('tsx') 7 | 8 | process.env.NODE_ENV = 'development' 9 | 10 | oclif.settings.debug = true; 11 | 12 | oclif.run().then(oclif.flush).catch(oclif.Errors.handle) 13 | })() 14 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | DEBUG=1 2 | 3 | AZURE_TTS_KEY= 4 | AZURE_TTS_REGION=southcentralus 5 | 6 | GOOGLE_CLIENT_ID= 7 | GOOGLE_CLIENT_SECRET= 8 | GOOGLE_REFRESH_TOKEN= 9 | YOUTUBE_REFRESH_TOKEN= 10 | GOOGLE_MAKERSUITE_API_KEY= 11 | 12 | CHROME_BIN= 13 | 14 | INSTAGRAM_EMAIL= 15 | INSTAGRAM_PASSWORD= 16 | 17 | IMAGE_GENERATOR_URL= -------------------------------------------------------------------------------- /src/commands/clean.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core'; 2 | 3 | import { CleanTmpService } from '../services'; 4 | 5 | export default class Clean extends Command { 6 | static description = 'Cleans TMP directory'; 7 | 8 | static examples = ['<%= config.bin %> <%= command.id %>']; 9 | 10 | public async run(): Promise { 11 | await new CleanTmpService().execute(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/randomNumbersBetween.ts: -------------------------------------------------------------------------------- 1 | export default function randomNumbersBetween( 2 | min: number, 3 | max: number, 4 | except?: number, 5 | ): number { 6 | const random = Math.floor(Math.random() * (max - min + 1) + min); 7 | 8 | if (random === except) { 9 | return randomNumbersBetween(min, max, except); 10 | } 11 | 12 | // min and max included 13 | return Math.floor(Math.random() * (max - min + 1) + min); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | const debug = process.env.DEBUG || 1; 2 | 3 | // eslint-disable-next-line 4 | export function log(message: any, prefix?: string, _?: any): void { 5 | if (debug) { 6 | console.log(`${prefix ? `[${prefix}]` : null} ${message}`); 7 | } 8 | } 9 | 10 | // eslint-disable-next-line 11 | export function error(message: any, prefix?: string, _?: any): void { 12 | console.log(`[ERROR] ${prefix ? `[${prefix}]` : null} ${message}`); 13 | throw new Error(message); 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [{ 7 | "name": "Python: Current File", 8 | "type": "python", 9 | "request": "launch", 10 | "program": "${file}", 11 | "console": "integratedTerminal", 12 | "justMyCode": true, 13 | "python": "/home/felippe/.pyenv/shims/python" 14 | }] 15 | } -------------------------------------------------------------------------------- /video/src/utils/measureWord.ts: -------------------------------------------------------------------------------- 1 | export default function measureLetter(letter: string, font: string, fontSize: number, fontWeight: number) { 2 | const canvas = document.createElement('canvas'); 3 | const context = canvas.getContext('2d'); 4 | 5 | if (!context) { 6 | throw new Error('Could not get canvas context'); 7 | } 8 | 9 | context.font = `${fontWeight} ${fontSize}px ${font}`; 10 | const metrics = context.measureText(letter); 11 | 12 | return { width: metrics.width, height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent }; 13 | } 14 | -------------------------------------------------------------------------------- /src/services/BundleVideoService.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { bundle } from '@remotion/bundler'; 3 | 4 | import { log } from '../utils/log'; 5 | import { getPath } from '../config/defaultPaths'; 6 | 7 | export default class BundleVideoService { 8 | public async execute(): Promise { 9 | log(`Bundling video`, 'BundleVideoService'); 10 | const bundled = await bundle( 11 | require.resolve( 12 | path.resolve(await getPath('remotion'), 'src', 'index.js'), 13 | ), 14 | ); 15 | 16 | return bundled; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/init/createTmpSymLink.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { Hook } from '@oclif/core'; 4 | import { ln } from 'shelljs'; 5 | 6 | import { getPath } from '../../config/defaultPaths'; 7 | 8 | const commandsToRun = ['clean', 'configure', 'content', 'create', 'remotion']; 9 | 10 | const hook: Hook<'init'> = async function (opts) { 11 | if (!commandsToRun.includes(opts.id || '')) return; 12 | 13 | const tmpSymlinkPath = path.resolve( 14 | __dirname, 15 | '..', 16 | '..', 17 | '..', 18 | 'public' 19 | ); 20 | 21 | if (!fs.existsSync(tmpSymlinkPath)) { 22 | ln('-sf', await getPath('tmp'), tmpSymlinkPath); 23 | } 24 | }; 25 | 26 | export default hook; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "jsx": "react-jsx", 7 | "rootDir": ".", 8 | "strict": true, 9 | "noImplicitAny": false, 10 | "strictPropertyInitialization": false, 11 | "baseUrl": "./src", 12 | "allowJs": true, 13 | "types": ["node"], 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "declaration": true, 20 | "importHelpers": true, 21 | "lib": ["es2022", "DOM"] 22 | }, 23 | "include": ["src/**/*", "video/**/*", "assets/**/*", "remotion.config.ts"] 24 | } -------------------------------------------------------------------------------- /src/hooks/prerun/copyLastContentToPublic.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '@oclif/core' 2 | import { getPath } from 'config/defaultPaths'; 3 | import fs from 'fs' 4 | import path from 'path' 5 | 6 | import { getLatestFileCreated } from '../../utils/getFiles'; 7 | 8 | const hook: Hook<'prerun'> = async function (opts) { 9 | if (opts.Command.name !== 'Create') { 10 | return; 11 | } 12 | 13 | const lastContentFile = await getLatestFileCreated('json', path.resolve(__dirname, '..', '..', '..', 'content')) 14 | const contentFileDestination = path.resolve(await getPath('content'), path.basename(lastContentFile)) 15 | 16 | console.log(contentFileDestination) 17 | 18 | fs.copyFileSync(lastContentFile, contentFileDestination) 19 | 20 | await new Promise((resolve) => { setTimeout(resolve, 5000) }) 21 | } 22 | 23 | export default hook 24 | -------------------------------------------------------------------------------- /src/models/InterfaceJsonContent.ts: -------------------------------------------------------------------------------- 1 | import Segment from "./Segments"; 2 | 3 | export default interface InterfaceJsonContent { 4 | timestamp: number; 5 | width: number; 6 | height: number; 7 | intro?: { text: string; url?: string; shortLink?: string }; 8 | end?: { text: string; url?: string; shortLink?: string }; 9 | news: { text: string; url?: string; shortLink?: string }[]; 10 | fps: number; 11 | title: string; 12 | thumbnail_text?: string; 13 | thumbnail_image_src?: string; 14 | duration?: number; 15 | date: string; 16 | renderData?: { 17 | text: string; 18 | duration: number; 19 | audioFilePath: string; 20 | segments: Segment[] 21 | }[]; 22 | youtube?: { 23 | viewCount: string; 24 | subscriberCount: string; 25 | videoCount: string; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/services/ExportDataService.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { log } from '../utils/log'; 5 | import { getPath } from '../config/defaultPaths'; 6 | import InterfaceJsonContent from '../models/InterfaceJsonContent'; 7 | 8 | export default class ExportDataService { 9 | private content: InterfaceJsonContent; 10 | 11 | constructor(content: InterfaceJsonContent) { 12 | this.content = content; 13 | } 14 | 15 | public async execute(filename?: string) { 16 | const dataFilename = filename || `${this.content.timestamp}.json`; 17 | 18 | log(`Exporting data to ${dataFilename}`, 'ExportDataService'); 19 | 20 | fs.writeFileSync( 21 | path.resolve(await getPath('content'), dataFilename), 22 | JSON.stringify(this.content, null, 4), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/services/ValidatesContentService.ts: -------------------------------------------------------------------------------- 1 | import { error, log } from '../utils/log'; 2 | import GetContentService from './GetContentService'; 3 | 4 | export default class ValidatesContentService { 5 | public async execute() { 6 | const { content } = await new GetContentService().execute(); 7 | 8 | const errors: string[] = []; 9 | 10 | if (!content.title) { 11 | errors.push('Title was not defined'); 12 | } 13 | 14 | if (!content.news || content.news.length === 0) { 15 | errors.push('News was not defined'); 16 | } 17 | 18 | if (errors.length) { 19 | error( 20 | `Found errors while validating \n${errors.join('\n')}`, 21 | 'ValidatesContentService', 22 | ); 23 | } 24 | 25 | log('Validation complete', 'ValidatesContentService'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/services/CleanTmpService.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | import { log } from '../utils/log'; 5 | import { getPath } from '../config/defaultPaths'; 6 | 7 | export default class CleanTmpService { 8 | private except = ['.gitkeep', 'example.json']; 9 | 10 | public async execute() { 11 | log(`Cleaning tmp`, 'CleanTmpService'); 12 | 13 | const tmpPath = await getPath('tmp'); 14 | 15 | const files = fs.readdirSync(tmpPath); 16 | 17 | files.forEach(file => { 18 | if (this.except.includes(file)) { 19 | return; 20 | } 21 | 22 | if (fs.statSync(path.resolve(tmpPath, file)).isDirectory()) { 23 | return fs.rmdirSync(path.resolve(tmpPath, file), { 24 | recursive: true, 25 | }); 26 | } 27 | 28 | fs.unlinkSync(path.resolve(tmpPath, file)); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/getFiles.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { error } from './log'; 5 | import { getPath } from '../config/defaultPaths'; 6 | 7 | export const getLatestFileCreated = async ( 8 | fileExt: string, 9 | dirPath?: string, 10 | ) => { 11 | if (!dirPath) { 12 | dirPath = await getPath('tmp'); 13 | } 14 | 15 | const files = fs.readdirSync(dirPath); 16 | 17 | const mostRecentlyCreatedFile = files 18 | .filter(file => path.extname(file) === `.${fileExt}`) 19 | .map(file => ({ 20 | file, 21 | creationTime: Number(file.split('.')[0].split('-')[0]), 22 | })) 23 | .filter(file => !Number.isNaN(file.creationTime)) 24 | .sort((a, b) => a.creationTime - b.creationTime) 25 | .pop()?.file; 26 | 27 | if (!mostRecentlyCreatedFile) { 28 | error(`No ${fileExt} file in ${dirPath}`); 29 | process.exit(1); 30 | } 31 | 32 | return path.resolve(dirPath, mostRecentlyCreatedFile); 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/CliProgress/terminal.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | 3 | // low-level terminal interactions 4 | export default class Terminal { 5 | public isTTY: boolean; 6 | 7 | constructor() { 8 | this.isTTY = process.stdout.isTTY; 9 | } 10 | 11 | cursorSave(): void { 12 | process.stdout.write('\x1B7'); 13 | } 14 | 15 | cursorRestore(): void { 16 | process.stdout.write('\x1B8'); 17 | } 18 | 19 | resetCursor(): void { 20 | if (!this.isTTY) { 21 | return; 22 | } 23 | 24 | readline.cursorTo(process.stdout, 0); 25 | } 26 | 27 | clearRight(): void { 28 | if (!this.isTTY) { 29 | return; 30 | } 31 | 32 | readline.clearLine(process.stdout, 1); 33 | } 34 | 35 | newline(): void { 36 | process.stdout.write('\n'); 37 | } 38 | 39 | write(s: string): void { 40 | process.stdout.write(s); 41 | } 42 | 43 | getWidth(): number { 44 | return process.stdout.columns || (process.stdout.isTTY ? 80 : 200); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-pullrequest.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Pull Requests 2 | 3 | # To disable automerge, simply remove this label from secrets 4 | 5 | on: 6 | pull_request: 7 | types: 8 | - opened 9 | - labeled 10 | workflow_dispatch: 11 | 12 | jobs: 13 | automerge: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Generate GitHub Token 17 | uses: tibdex/github-app-token@v1 18 | id: generate-token 19 | with: 20 | app_id: ${{ secrets.ID_GITHUB_APP }} 21 | private_key: ${{ secrets.PRIVATE_KEY_GITHUB_APP }} 22 | 23 | - name: Merge 24 | uses: FelippeChemello/merge-bot@v0.4.10 25 | with: 26 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 27 | reviewers: false 28 | blocking_labels: do not merge 29 | method: squash 30 | delete_source_branch: false 31 | authors: codestack-me[bot],dependabot[bot] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joseph Oliveira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /video/src/Wrappers/index.tsx: -------------------------------------------------------------------------------- 1 | import {getInputProps, useVideoConfig} from 'remotion'; 2 | import styled from 'styled-components'; 3 | import {YoutubeWrapper} from './youtubeWrapper'; 4 | 5 | type WrapperProps = { 6 | title: string; 7 | show: boolean; 8 | children: React.ReactNode; 9 | }; 10 | 11 | type VideoWrapperProps = { 12 | videoWidth: number; 13 | videoHeight: number; 14 | }; 15 | 16 | const VideoWrapper = styled.div` 17 | background: #0c2d48; 18 | display: flex; 19 | flex-direction: column; 20 | gap: 30px; 21 | width: ${(props) => props.videoWidth}px; 22 | height: ${(props) => props.videoHeight}px; 23 | overflow: hidden; 24 | `; 25 | 26 | const {destination} = getInputProps(); 27 | 28 | export const Wrapper: React.FC = ({children, title, show}) => { 29 | const { 30 | width: videoWidth, 31 | height: videoHeight, 32 | } = useVideoConfig(); 33 | 34 | if (destination === 'youtube' && show) { 35 | return {children}; 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/config/secrets.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import dotenv from 'dotenv'; 4 | import { Config } from '@oclif/core'; 5 | 6 | export const loadSecrets = async () => { 7 | const config = await Config.load(); 8 | const secretsFilePath = path.resolve(config.configDir, 'secrets.json'); 9 | 10 | const { parsed: dotEnv } = dotenv.config(); 11 | 12 | if (!fs.existsSync(secretsFilePath)) { 13 | return dotenv; 14 | } 15 | 16 | const secretsFileContent = fs.readFileSync(secretsFilePath, 'utf8'); 17 | const secrets = JSON.parse(secretsFileContent); 18 | 19 | Object.entries(secrets).forEach(([key, value]) => { 20 | process.env[key] = (dotEnv && dotEnv[key]) ?? String(value); 21 | }); 22 | 23 | return secrets; 24 | }; 25 | 26 | export const saveSecrets = async (secrets: { [key: string]: unknown }) => { 27 | const config = await Config.load(); 28 | const secretsFilePath = path.resolve(config.configDir, 'secrets.json'); 29 | 30 | if (!fs.existsSync(config.configDir)) { 31 | fs.mkdirSync(config.configDir, { recursive: true }); 32 | } 33 | 34 | console.log('Saving secrets to', secretsFilePath) 35 | 36 | // fs.writeFileSync(secretsFilePath, JSON.stringify(secrets, null, 2)); 37 | }; 38 | -------------------------------------------------------------------------------- /assets/mouse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /video/src/Podcast/Arc.tsx: -------------------------------------------------------------------------------- 1 | import {interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; 2 | 3 | export const Arc: React.FC<{ 4 | rotation: number; 5 | }> = ({rotation}) => { 6 | const frame = useCurrentFrame(); 7 | const {height, width, fps} = useVideoConfig(); 8 | const rx = 180; 9 | const ry = 400; 10 | const arcLength = Math.PI * 2 * Math.sqrt((rx * rx + ry * ry) / 2); 11 | 12 | const progress = spring({ 13 | frame: frame, 14 | fps, 15 | config: { 16 | damping: 100, 17 | mass: 10, 18 | }, 19 | }); 20 | 21 | const opacity = interpolate(progress, [0, 0.2], [0, 0.7], { 22 | extrapolateRight: 'clamp', 23 | extrapolateLeft: 'clamp', 24 | }); 25 | 26 | const strokeWidth = interpolate(progress, [0, 1], [200, 60]); 27 | 28 | return ( 29 | 36 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/config/defaultPaths.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { Config } from '@oclif/core'; 4 | 5 | const assetsPath = path.resolve(__dirname, '..', '..', 'assets'); 6 | const remotionPath = path.resolve(__dirname, '..', '..', 'video'); 7 | const publicPath = path.resolve(__dirname, __dirname.includes('dist') ? '..' : '', '..', '..', 'public'); 8 | 9 | export const getPath = async ( 10 | pathname: 'content' | 'assets' | 'tmp' | 'remotion' | 'public', 11 | ) => { 12 | const config = await Config.load(); 13 | 14 | switch (pathname) { 15 | case 'content': 16 | const contentPath = path.resolve(config.cacheDir); 17 | 18 | if (!fs.existsSync(contentPath)) { 19 | fs.mkdirSync(contentPath, { recursive: true }); 20 | } 21 | 22 | return contentPath; 23 | case 'assets': 24 | return assetsPath; 25 | case 'tmp': 26 | const tmpPath = path.resolve(config.cacheDir); 27 | 28 | if (!fs.existsSync(tmpPath)) { 29 | fs.mkdirSync(tmpPath, { recursive: true }); 30 | } 31 | 32 | return tmpPath; 33 | case 'remotion': 34 | return remotionPath; 35 | case 'public': 36 | return publicPath; 37 | default: 38 | throw new Error(`Unknown path: ${pathname}`); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import GetContentService from './GetContentService'; 2 | import TextToSpeechService from './TextToSpeechService'; 3 | import ExportDataService from './ExportDataService'; 4 | import RenderVideoService from './RenderVideoService'; 5 | import YoutubeUploadService from './YoutubeUploadService'; 6 | import CreateThumbnailService from './CreateThumnailService'; 7 | import BundleVideoService from './BundleVideoService'; 8 | import CreateContentTemplateService from './CreateContentTemplateService'; 9 | import CleanTmpService from './CleanTmpService'; 10 | import InstagramUploadService from './InstagramUploadService'; 11 | import ValidatesContentService from './ValidatesContentService'; 12 | import MailToJsonService from './MailToJsonService'; 13 | import GetYoutubeInfoService from './GetYoutubeInfoService'; 14 | import GenerateTitleService from './GenerateTitleService'; 15 | import GenerateImageService from './GenerateImageService'; 16 | 17 | export { 18 | GetContentService, 19 | TextToSpeechService, 20 | RenderVideoService, 21 | ExportDataService, 22 | YoutubeUploadService, 23 | CreateContentTemplateService, 24 | CreateThumbnailService, 25 | BundleVideoService, 26 | CleanTmpService, 27 | InstagramUploadService, 28 | ValidatesContentService, 29 | MailToJsonService, 30 | GetYoutubeInfoService, 31 | GenerateTitleService, 32 | GenerateImageService, 33 | }; 34 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | 3 | import { getPath } from '../config/defaultPaths'; 4 | 5 | export default class Info extends Command { 6 | static description = 'Get info about the CLI'; 7 | 8 | static examples = [ 9 | '<%= config.bin %> <%= command.id %>', 10 | '<%= config.bin %> <%= command.id %>', 11 | '<%= config.bin %> <%= command.id %>', 12 | ]; 13 | 14 | static flags = { 15 | dir: Flags.boolean({ 16 | char: 'd', 17 | description: 'Get content directory path', 18 | }), 19 | }; 20 | 21 | static args = [ 22 | { 23 | name: 'about', 24 | required: true, 25 | description: 'About what you want infos?', 26 | options: ['content', 'tmp'], 27 | }, 28 | ]; 29 | 30 | public async run(): Promise { 31 | const { 32 | args, 33 | flags: { dir }, 34 | } = await this.parse(Info); 35 | 36 | switch (args.about) { 37 | case 'content': 38 | if (dir) { 39 | this.log(await getPath('content')); 40 | } 41 | break; 42 | case 'tmp': 43 | if (dir) { 44 | this.log(await getPath('tmp')); 45 | } 46 | break; 47 | } 48 | 49 | this.config; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /video/src/Podcast/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | continueRender, 3 | delayRender, 4 | interpolate, 5 | spring, 6 | useCurrentFrame, 7 | useVideoConfig, 8 | } from 'remotion'; 9 | import avatar from '../../../assets/Avatar.png'; 10 | 11 | export const Logo: React.FC = () => { 12 | const videoConfig = useVideoConfig(); 13 | const frame = useCurrentFrame(); 14 | 15 | const orientation = 16 | videoConfig.width > videoConfig.height ? 'landscape' : 'portrait'; 17 | 18 | const logoEntry = spring({ 19 | fps: videoConfig.fps, 20 | from: -150, 21 | to: orientation === 'landscape' ? 30 : 50, 22 | frame, 23 | config: { 24 | mass: 0.8, 25 | damping: 10, 26 | }, 27 | }); 28 | 29 | return ( 30 |
43 | 53 |

60 | CodeStack 61 |

62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /assets/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'ProductSans'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url(ProductSans-Regular.woff) format('woff'); 6 | } 7 | 8 | @font-face { 9 | font-family: 'ProductSans'; 10 | font-style: normal; 11 | font-weight: 200; 12 | src: url(ProductSans-Thin.woff) format('woff'); 13 | } 14 | 15 | @font-face { 16 | font-family: 'ProductSans'; 17 | font-style: normal; 18 | font-weight: 600; 19 | src: url(ProductSans-Black.woff) format('woff'); 20 | } 21 | 22 | @font-face { 23 | font-family: 'Nunito'; 24 | font-style: normal; 25 | font-weight: 200; 26 | src: url(Nunito-ExtraLight.woff) format('woff'); 27 | } 28 | 29 | @font-face { 30 | font-family: 'Nunito'; 31 | font-style: normal; 32 | font-weight: 300; 33 | src: url(Nunito-Light.woff) format('woff'); 34 | } 35 | 36 | @font-face { 37 | font-family: 'Nunito'; 38 | font-style: normal; 39 | font-weight: 400; 40 | src: url(Nunito-Regular.woff) format('woff'); 41 | } 42 | 43 | @font-face { 44 | font-family: 'Nunito'; 45 | font-style: normal; 46 | font-weight: 600; 47 | src: url(Nunito-SemiBold.woff) format('woff'); 48 | } 49 | 50 | @font-face { 51 | font-family: 'Nunito'; 52 | font-style: normal; 53 | font-weight: 700; 54 | src: url(Nunito-Bold.woff) format('woff'); 55 | } 56 | 57 | @font-face { 58 | font-family: 'Courier Prime'; 59 | font-style: normal; 60 | font-weight: 400; 61 | src: url(CourierPrime-Regular.woff) format('woff'); 62 | } 63 | -------------------------------------------------------------------------------- /src/services/GetContentService.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { error, log } from '../utils/log'; 5 | import { getLatestFileCreated } from '../utils/getFiles'; 6 | import { getPath } from '../config/defaultPaths'; 7 | import InterfaceJsonContent from '../models/InterfaceJsonContent'; 8 | import format from '../config/format'; 9 | 10 | export default class GetContentService { 11 | // eslint-disable-next-line 12 | public async execute(filename?: string, videoFormat?: 'portrait' | 'landscape' | 'square'): Promise<{ content: InterfaceJsonContent, file: string }> { 13 | const contentPath = await getPath('content'); 14 | 15 | const contentFilePath = filename 16 | ? path.resolve(contentPath, filename) 17 | : await getLatestFileCreated('json', contentPath); 18 | 19 | log(`Getting content from ${contentFilePath}`, 'GetContentService'); 20 | 21 | try { 22 | const content = fs.readFileSync(contentFilePath, { 23 | encoding: 'utf-8', 24 | }); 25 | 26 | const jsonContent = JSON.parse(content) as InterfaceJsonContent; 27 | 28 | if (videoFormat) { 29 | jsonContent.width = format[videoFormat].width; 30 | jsonContent.height = format[videoFormat].height; 31 | } 32 | 33 | return { content: jsonContent, file: contentFilePath }; 34 | } catch { 35 | error(`${contentFilePath} not found`, 'GetContentService'); 36 | process.exit(1); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/content.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core'; 2 | 3 | import { 4 | CreateContentTemplateService, 5 | MailToJsonService, 6 | ValidatesContentService, 7 | } from '../services'; 8 | 9 | export default class Content extends Command { 10 | static description = 'Generate or validate content file'; 11 | 12 | static examples = [ 13 | '<%= config.bin %> <%= command.id %> mail', 14 | '<%= config.bin %> <%= command.id %> template [DESCRIPTION]', 15 | '<%= config.bin %> <%= command.id %> validate', 16 | ]; 17 | 18 | static args = [ 19 | { 20 | name: 'command', 21 | required: true, 22 | description: 'Command to run', 23 | options: ['mail', 'template', 'validate'], 24 | }, 25 | { 26 | name: 'description', 27 | required: false, 28 | description: 'Description of the template', 29 | }, 30 | ]; 31 | 32 | public async run(): Promise { 33 | const { args } = await this.parse(Content); 34 | 35 | switch (args.command) { 36 | case 'mail': 37 | await new MailToJsonService().execute(); 38 | break; 39 | case 'template': 40 | const { description } = args; 41 | if (!description) throw new Error('Missing description'); 42 | 43 | await new CreateContentTemplateService().execute(description); 44 | break; 45 | case 'validate': 46 | await new ValidatesContentService().execute(); 47 | break; 48 | } 49 | 50 | this.config 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /video/src/Video.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { 3 | Composition, 4 | delayRender, 5 | continueRender, 6 | getInputProps, 7 | } from 'remotion'; 8 | import { Main } from './Main'; 9 | import { Thumbnail } from './Thumbnail'; 10 | 11 | import '../../assets/fonts.css'; 12 | import InterfaceJsonContent from 'models/InterfaceJsonContent'; 13 | 14 | const { 15 | content, 16 | durationInFrames 17 | } = getInputProps() as { 18 | content: InterfaceJsonContent, 19 | durationInFrames: number 20 | } 21 | 22 | export const RemotionVideo: React.FC = () => { 23 | if (!content || !durationInFrames) { 24 | throw new Error(`Missing information. Content: ${!!content}, renderData: ${!!content?.renderData}, durationInFrames: ${!!durationInFrames}`); 25 | } 26 | 27 | return ( 28 | <> 29 | 40 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /video/src/Podcast/Transition.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | spring, 4 | SpringConfig, 5 | useCurrentFrame, 6 | useVideoConfig, 7 | Audio, 8 | delayRender, 9 | } from 'remotion'; 10 | import styled from 'styled-components'; 11 | 12 | import transitionAudioSrc from '../../../assets/transition.mp3'; 13 | import {Arc} from './Arc'; 14 | 15 | const Container = styled.div` 16 | flex: 1; 17 | justify-content: 'center'; 18 | align-items: 'center'; 19 | `; 20 | 21 | export const Transition: React.FC<{}> = () => { 22 | const frame = useCurrentFrame(); 23 | const videoConfig = useVideoConfig(); 24 | const springConfig: SpringConfig = { 25 | damping: 10, 26 | mass: 0.1, 27 | stiffness: 100, 28 | overshootClamping: true, 29 | }; 30 | const scale = spring({ 31 | config: springConfig, 32 | from: 0, 33 | to: 1, 34 | fps: videoConfig.fps, 35 | frame: frame, 36 | }); 37 | 38 | const arcs = ( 39 | <> 40 | 41 | 42 | 43 | 44 | ); 45 | 46 | return ( 47 | <> 48 |