├── requirements.txt
├── .eslintrc.json
├── docs
├── frame_02.jpg
└── screenshot.png
├── public
└── favicon.ico
├── components
├── box.tsx
├── output.tsx
├── tabs.tsx
└── video-form.tsx
├── scripts
├── test.py
├── download-audio.sh
├── split.sh
├── transcribe.py
└── translate.py
├── next.config.js
├── .prettierrc.yaml
├── .gitignore
├── tsconfig.json
├── pages
├── _document.tsx
├── api
│ ├── audio.ts
│ ├── translate.ts
│ └── transcript.ts
└── index.tsx
├── package.json
├── utils
├── shell.ts
└── api-client.ts
├── LICENSE
├── README.md
└── stitches.config.ts
/requirements.txt:
--------------------------------------------------------------------------------
1 | openai
2 | yt-dlp
3 | pysrt
4 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/docs/frame_02.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftzdog/vlog-translator/HEAD/docs/frame_02.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftzdog/vlog-translator/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftzdog/vlog-translator/HEAD/docs/screenshot.png
--------------------------------------------------------------------------------
/components/box.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@stitches/react'
2 |
3 | export const Box = styled('div', {})
4 |
--------------------------------------------------------------------------------
/scripts/test.py:
--------------------------------------------------------------------------------
1 | import os
2 | import openai
3 | openai.api_key = os.getenv('OPENAI_API_KEY')
4 | print(openai.Model.list())
5 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true
4 | }
5 |
6 | module.exports = nextConfig
7 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | arrowParens: 'avoid'
2 | singleQuote: true
3 | bracketSpacing: true
4 | endOfLine: 'lf'
5 | semi: false
6 | tabWidth: 2
7 | trailingComma: 'none'
8 |
--------------------------------------------------------------------------------
/scripts/download-audio.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | VIDEO_ID=$1
4 |
5 | [ -z "$VIDEO_ID" ] && echo "ERROR: No video ID specified" && exit 1
6 |
7 | yt-dlp "https://www.youtube.com/watch?v=$VIDEO_ID" --format m4a -o "./tmp/%(id)s.%(ext)s" 2>&1
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | tmp
3 |
4 | # dependencies
5 | /node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/scripts/split.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env fish
2 | # Description: Split an
3 | # Requires: ffmpeg, jq
4 | # Author: Hasan Arous
5 | # License: MIT
6 |
7 | set in $argv[1]
8 | set out $argv[2]
9 | set splits ""
10 | set chapters (ffprobe -i $in -print_format json -show_chapters | jq -r '.chapters[] | .start_time + " " + .end_time')
11 |
12 | for chapter in $chapters
13 | set start_time (echo $chapter | awk '{print $1}')
14 | set end_time (echo $chapter | awk '{print $2}')
15 | echo $start_time '|' $end_time
16 | ffmpeg -i "$in" -c copy -ss $start_time -to $end_time $out/$start_time.m4a
17 | end
18 |
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Head, Html, Main, NextScript } from 'next/document'
2 | import { getCssText, globalCss } from '../stitches.config'
3 |
4 | const globalStyles = globalCss({
5 | body: { fontFamily: '$system', padding: 0, margin: 0 }
6 | })
7 |
8 | export default function MyDocument() {
9 | globalStyles()
10 |
11 | return (
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "next build",
5 | "dev": "next dev",
6 | "start": "next start"
7 | },
8 | "dependencies": {
9 | "@radix-ui/react-form": "^0.0.2",
10 | "@radix-ui/react-tabs": "^1.0.3",
11 | "@stitches/react": "1.2.8",
12 | "next": "latest",
13 | "react": "18.2.0",
14 | "react-dom": "18.2.0"
15 | },
16 | "devDependencies": {
17 | "@types/node": "^18.0.6",
18 | "@types/react": "^18.0.15",
19 | "@types/react-dom": "^18.0.6",
20 | "eslint": "^8.37.0",
21 | "eslint-config-next": "^13.2.4",
22 | "prettier": "^2.8.7",
23 | "typescript": "^4.7.4"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pages/api/audio.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { spawn } from 'child_process'
3 | import path from 'path'
4 | import { transferChildProcessOutput } from '../../utils/shell'
5 |
6 | export default function GET(
7 | request: NextApiRequest,
8 | response: NextApiResponse
9 | ) {
10 | const video_id = request.query.video_id as string
11 | if (typeof video_id !== 'string') {
12 | response.status(400).json({ error: 'Invalid request' })
13 | return
14 | }
15 |
16 | console.log('video ID:', video_id)
17 | const cmd = spawn(path.join(process.cwd(), 'scripts/download-audio.sh'), [
18 | video_id || ''
19 | ])
20 | transferChildProcessOutput(cmd, response)
21 | }
22 |
--------------------------------------------------------------------------------
/pages/api/translate.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { spawn } from 'child_process'
3 | import path from 'path'
4 | import { transferChildProcessOutput } from '../../utils/shell'
5 |
6 | export default function POST(
7 | request: NextApiRequest,
8 | response: NextApiResponse
9 | ) {
10 | const srt = request.body
11 | if (typeof srt !== 'string') {
12 | response.status(400).json({ error: 'Invalid request' })
13 | return
14 | }
15 |
16 | const cmd = spawn(
17 | 'python3',
18 | [path.join(process.cwd(), 'scripts/translate.py')],
19 | {
20 | cwd: process.cwd()
21 | }
22 | )
23 | cmd.stdin.write(srt)
24 | cmd.stdin.end()
25 | transferChildProcessOutput(cmd, response)
26 | }
27 |
--------------------------------------------------------------------------------
/pages/api/transcript.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { spawn } from 'child_process'
3 | import path from 'path'
4 | import { transferChildProcessOutput } from '../../utils/shell'
5 |
6 | export default function GET(
7 | request: NextApiRequest,
8 | response: NextApiResponse
9 | ) {
10 | const video_id = request.query.video_id as string
11 | if (typeof video_id !== 'string') {
12 | response.status(400).json({ error: 'Invalid request' })
13 | return
14 | }
15 |
16 | console.log('video ID:', video_id)
17 | const cmd = spawn(
18 | 'python3',
19 | [path.join(process.cwd(), 'scripts/transcribe.py'), video_id || ''],
20 | {
21 | cwd: process.cwd()
22 | }
23 | )
24 | transferChildProcessOutput(cmd, response)
25 | }
26 |
--------------------------------------------------------------------------------
/components/output.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import { styled } from '@stitches/react'
3 | import { Box } from './box'
4 |
5 | type Props = {
6 | children: string | undefined
7 | }
8 |
9 | export const Output: React.FC = ({ children }) => {
10 | const refBox = useRef(null)
11 |
12 | useEffect(() => {
13 | const elBox = refBox.current
14 | if (elBox) {
15 | elBox.scrollTop = elBox.scrollHeight
16 | }
17 | }, [children])
18 | return (
19 |
24 | {children}
25 |
26 | )
27 | }
28 |
29 | export const Pre = styled('pre', {
30 | margin: '1em',
31 | fontSize: '1.2em',
32 | whiteSpace: 'pre-wrap'
33 | })
34 |
--------------------------------------------------------------------------------
/utils/shell.ts:
--------------------------------------------------------------------------------
1 | import { ChildProcessWithoutNullStreams } from 'child_process'
2 | import { NextApiResponse } from 'next'
3 |
4 | export function transferChildProcessOutput(
5 | cmd: ChildProcessWithoutNullStreams,
6 | response: NextApiResponse
7 | ) {
8 | cmd.on('close', code => {
9 | console.log('Finished command. Exit code:', code)
10 | })
11 | cmd.stderr.on('data', chunk => {
12 | const chunkStr = chunk.toString('utf-8')
13 | console.error('[Error]', chunkStr)
14 | response.write(
15 | chunkStr
16 | .split('\n')
17 | .map((line: string) => '[Error] ' + line)
18 | .join('\n')
19 | )
20 | })
21 |
22 | response.writeHead(200, {
23 | 'Content-Type': 'text/plain',
24 | 'Cache-Control': 'no-cache',
25 | 'Content-Encoding': 'none'
26 | })
27 | cmd.stdout.pipe(response)
28 | }
29 |
--------------------------------------------------------------------------------
/scripts/transcribe.py:
--------------------------------------------------------------------------------
1 | import os
2 | import openai
3 | import sys
4 | openai.api_key = os.getenv('OPENAI_API_KEY')
5 | audio_filename = sys.argv[1]
6 | audio_file_path = os.path.join(os.getcwd(), 'tmp', audio_filename)
7 |
8 | prompt_file_path = os.path.join(os.getcwd(), 'tmp', 'prompt.txt')
9 | f = open(prompt_file_path, "r")
10 | prompt = f.read()
11 |
12 | audio_file = open(audio_file_path, 'rb')
13 | transcript = openai.audio.transcriptions.create(
14 | file=audio_file,
15 | model="whisper-1",
16 | response_format="srt",
17 | prompt=prompt
18 | # prompt=(
19 | # 'I am a programmer. My name is Takuya. '
20 | # 'This is a vlog about my app development, tech review, lifehacks, etc. '
21 | # 'I have my own product called Inkdrop. '
22 | # 'My YouTube channel is called devaslife. '
23 | # )
24 | )
25 | print(transcript)
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Takuya Matsuyama
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.
22 |
--------------------------------------------------------------------------------
/scripts/translate.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import openai
4 | import pysrt
5 |
6 | openai.api_key = os.getenv("OPENAI_API_KEY")
7 | input_data = sys.stdin.read()
8 | subs = pysrt.from_string(input_data)
9 |
10 | prompt_base = (
11 | "You are going to be a good translator. "
12 | "Here is a part of the transcript of my vlog. "
13 | "I am talking about my product called Inkdrop, "
14 | "which is a Markdown note-taking app designed for developers. "
15 | "Translate the following text precisely into Japanese "
16 | "with the polite and formal style. "
17 | "Translate from [START] to [END]:\n[START]\n"
18 | )
19 |
20 |
21 | def translate_text(text):
22 | prompt = prompt_base
23 | prompt += text + "\n[END]"
24 |
25 | response = openai.completions.create(
26 | model="text-davinci-003",
27 | prompt=prompt,
28 | max_tokens=3000,
29 | temperature=0,
30 | )
31 | translated = response.choices[0].text.strip()
32 | if translated.startswith('「'):
33 | translated = translated[1:]
34 | if translated.endswith('」'):
35 | translated = translated[:-1]
36 | return translated
37 |
38 |
39 | for index, subtitle in enumerate(subs):
40 | subtitle.text = translate_text(subtitle.text)
41 | print(subtitle, flush=True)
42 |
--------------------------------------------------------------------------------
/components/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as Tabs from '@radix-ui/react-tabs'
2 | import { styled } from '@stitches/react'
3 |
4 | export const TabsRoot = styled(Tabs.Root, {
5 | display: 'flex',
6 | flexDirection: 'column',
7 | marginTop: '1.5em',
8 | minHeight: 0
9 | })
10 |
11 | export const TabsList = styled(Tabs.List, {
12 | flexShrink: 0,
13 | display: 'flex',
14 | borderBottom: `1px solid $gray400`
15 | })
16 |
17 | export const TabsTrigger = styled(Tabs.Trigger, {
18 | all: 'unset',
19 | fontFamily: 'inherit',
20 | backgroundColor: 'white',
21 | height: 45,
22 | flex: 1,
23 | display: 'flex',
24 | alignItems: 'center',
25 | justifyContent: 'center',
26 | fontSize: 15,
27 | lineHeight: 1,
28 | color: '$foreground',
29 | userSelect: 'none',
30 | '&:first-child': { borderTopLeftRadius: 6 },
31 | '&:last-child': { borderTopRightRadius: 6 },
32 | '&:hover': { color: '$purple600' },
33 | '&[data-state="active"]': {
34 | color: '$purple600',
35 | boxShadow: 'inset 0 -1px 0 0 currentColor, 0 1px 0 0 currentColor'
36 | }
37 | })
38 |
39 | export const TabsContent = styled(Tabs.Content, {
40 | display: 'flex',
41 | flexDirection: 'column',
42 | minHeight: 0,
43 | flexGrow: 1,
44 | paddingTop: 1,
45 | backgroundColor: 'white',
46 | borderBottomLeftRadius: 6,
47 | borderBottomRightRadius: 6,
48 | outline: 'none'
49 | })
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Vlog Transcription & Japanese Translation Tool
2 |
3 | A personal tool for transcribing & translating my vlogs into Japanese.
4 |
5 | 
6 |
7 | ## Tutorial video
8 |
9 | [](https://youtu.be/UNGi144eVbI)
10 |
11 | ## Ingredients
12 |
13 | - Python and [pip](https://pypi.org/project/pip/)
14 | - [pysrt](https://github.com/byroot/pysrt) - Python parser for SubRip (srt) files
15 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A youtube-dl fork with additional features and fixes
16 | - [openai](https://github.com/openai/openai-python) - The OpenAI Python library provides convenient access to the OpenAI API from applications written in the Python language
17 | - Next.js
18 | - [Radix UI](https://www.radix-ui.com/) - Unstyled, accessible components for building high‑quality design systems and web apps in React
19 | - [Stitches](https://github.com/modulz/stitches) - CSS-in-JS Library
20 |
21 | ## How to use
22 |
23 | 1. Get your OpenAI API Key [here](https://platform.openai.com/account/api-keys)
24 | 2. Set an environment variable `$OPENAI_API_KEY`
25 | 3. Run the following commands
26 |
27 | ```bash
28 | pip install -r requirements.txt
29 | npm i
30 | npm run dev
31 | ```
32 |
33 | ## Project Structure
34 |
35 | ```
36 | PROJECT_ROOT
37 | ├── components # React components
38 | ├── pages # Pages
39 | │ └── api # API routes
40 | ├── public
41 | ├── scripts # Python scripts
42 | ├── tmp # Temporary files
43 | └── utils # Utility modules
44 | ```
45 |
46 | ## License
47 |
48 | MIT License.
49 |
50 | ---
51 |
52 | Looking for a Markdown note-taking app? Check out my app called Inkdrop:
53 |
54 | [](https://www.inkdrop.app/)
55 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import { styled } from '../stitches.config'
3 | import { Box } from '../components/box'
4 | import { Output } from '../components/output'
5 | import { VideoForm } from '../components/video-form'
6 | import {
7 | TabsContent,
8 | TabsList,
9 | TabsRoot,
10 | TabsTrigger
11 | } from '../components/tabs'
12 | import { useState } from 'react'
13 | import { extractVideoIdFromUrl, processVideo } from '../utils/api-client'
14 |
15 | const Text = styled('p', {
16 | fontFamily: '$system',
17 | color: '$hiContrast'
18 | })
19 |
20 | const Container = styled('div', {
21 | display: 'flex',
22 | flexDirection: 'column',
23 | height: '100vh',
24 | marginY: 0,
25 | marginX: 'auto',
26 | paddingX: '$3',
27 | paddingY: 0,
28 |
29 | variants: {
30 | size: {
31 | 1: {
32 | maxWidth: '300px'
33 | },
34 | 2: {
35 | maxWidth: '585px'
36 | },
37 | 3: {
38 | maxWidth: '865px'
39 | }
40 | }
41 | }
42 | })
43 |
44 | export default function Home() {
45 | const [isProcessing, setProcessing] = useState(false)
46 | const [progressOutput, setProgressOutput] = useState('')
47 | const [activeTab, setActiveTab] = useState('progress')
48 | const [resultTranscript, setResultTranscript] = useState('')
49 |
50 | const handleStartProcessing = async (videoUrl: string) => {
51 | const videoId = extractVideoIdFromUrl(videoUrl)
52 | if (typeof videoId === 'string') {
53 | setResultTranscript('')
54 | setProcessing(true)
55 |
56 | const transcriptInJapanese = await processVideo(videoId, message => {
57 | setProgressOutput(prev => prev + message)
58 | })
59 | if (transcriptInJapanese) {
60 | setResultTranscript(transcriptInJapanese)
61 | }
62 |
63 | setProcessing(false)
64 | setActiveTab('result')
65 | } else {
66 | alert('Invalid URL')
67 | }
68 | }
69 |
70 | return (
71 |
72 |
73 | YouTube Transcription & Japanese Translation
74 |
75 |
76 | Vlog Transcription & Japanese Translation Tool
77 |
81 |
82 |
83 | Progress
84 | Result
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/stitches.config.ts:
--------------------------------------------------------------------------------
1 | import type * as Stitches from '@stitches/react'
2 | import { createStitches } from '@stitches/react'
3 |
4 | export const {
5 | config,
6 | createTheme,
7 | css,
8 | getCssText,
9 | globalCss,
10 | styled,
11 | theme
12 | } = createStitches({
13 | theme: {
14 | colors: {
15 | hiContrast: 'hsl(206,10%,5%)',
16 | loContrast: 'white',
17 | foreground: '#202020',
18 |
19 | gray100: 'hsl(206,22%,99%)',
20 | gray200: 'hsl(206,12%,97%)',
21 | gray300: 'hsl(206,11%,92%)',
22 | gray400: 'hsl(206,10%,84%)',
23 | gray500: 'hsl(206,10%,76%)',
24 | gray600: 'hsl(206,10%,44%)',
25 |
26 | purple100: 'hsl(252,100%,99%)',
27 | purple200: 'hsl(252,100%,98%)',
28 | purple300: 'hsl(252,100%,94%)',
29 | purple400: 'hsl(252,75%,84%)',
30 | purple500: 'hsl(252,78%,60%)',
31 | purple600: 'hsl(252,80%,53%)',
32 |
33 | red100: 'hsl(0,100%,99%)',
34 | red200: 'hsl(0,100%,98%)',
35 | red300: 'hsl(0,100%,94%)',
36 | red400: 'hsl(0,75%,84%)',
37 | red500: 'hsl(0,78%,60%)',
38 | red600: 'hsl(0,80%,53%)'
39 | },
40 | shadows: {
41 | gray100: 'hsl(206,22%,99%)',
42 | gray200: 'hsl(206,12%,97%)',
43 | gray300: 'hsl(206,11%,92%)',
44 | gray400: 'hsl(206,10%,84%)',
45 | gray500: 'hsl(206,10%,76%)',
46 | gray600: 'hsl(206,10%,44%)',
47 |
48 | purple100: 'hsl(252,100%,99%)',
49 | purple200: 'hsl(252,100%,98%)',
50 | purple300: 'hsl(252,100%,94%)',
51 | purple400: 'hsl(252,75%,84%)',
52 | purple500: 'hsl(252,78%,60%)',
53 | purple600: 'hsl(252,80%,53%)'
54 | },
55 | space: {
56 | 1: '5px',
57 | 2: '10px',
58 | 3: '15px',
59 | 4: '20px',
60 | 5: '25px',
61 | 6: '35px'
62 | },
63 | sizes: {
64 | 1: '5px',
65 | 2: '10px',
66 | 3: '15px',
67 | 4: '20px',
68 | 5: '25px',
69 | 6: '35px'
70 | },
71 | fontSizes: {
72 | 1: '12px',
73 | 2: '13px',
74 | 3: '15px',
75 | 4: '17px',
76 | 5: '19px',
77 | 6: '21px'
78 | },
79 | fonts: {
80 | system: 'system-ui'
81 | }
82 | },
83 | utils: {
84 | marginX: (value: Stitches.PropertyValue<'margin'>) => ({
85 | marginLeft: value,
86 | marginRight: value
87 | }),
88 | marginY: (value: Stitches.PropertyValue<'margin'>) => ({
89 | marginTop: value,
90 | marginBottom: value
91 | }),
92 | paddingX: (value: Stitches.PropertyValue<'padding'>) => ({
93 | paddingLeft: value,
94 | paddingRight: value
95 | }),
96 | paddingY: (value: Stitches.PropertyValue<'padding'>) => ({
97 | paddingTop: value,
98 | paddingBottom: value
99 | })
100 | },
101 | media: {
102 | bp1: '(min-width: 520px)',
103 | bp2: '(min-width: 900px)'
104 | }
105 | })
106 |
--------------------------------------------------------------------------------
/utils/api-client.ts:
--------------------------------------------------------------------------------
1 | export function extractVideoIdFromUrl(url: string) {
2 | return new URL(url).searchParams.get('v')
3 | }
4 |
5 | type ProgressCallback = (output: string) => void
6 |
7 | export async function processVideo(
8 | videoId: string,
9 | callback: ProgressCallback
10 | ): Promise {
11 | callback('Downloading audio...\n')
12 | await downloadAudio(videoId, callback)
13 |
14 | callback('\nTranscribing audio. It takes a while...\n')
15 | const srt = await transcribe(videoId, callback)
16 |
17 | if (srt) {
18 | callback('\nTranslating text...\n')
19 | const result = await translate(srt, callback)
20 | callback('\nDone!\n')
21 | return result
22 | }
23 |
24 | return false
25 | }
26 |
27 | export async function downloadAudio(
28 | videoId: string,
29 | onProgress: ProgressCallback
30 | ) {
31 | const res = await fetch(
32 | `/api/audio?${new URLSearchParams({ video_id: videoId })}`,
33 | {}
34 | )
35 | const reader = res.body?.getReader()
36 |
37 | if (reader) {
38 | return streamResponse(reader, onProgress)
39 | } else {
40 | return false
41 | }
42 | }
43 |
44 | export async function transcribe(
45 | videoId: string,
46 | onProgress: ProgressCallback
47 | ): Promise {
48 | const res = await fetch(
49 | `/api/transcript?${new URLSearchParams({ video_id: videoId })}`,
50 | {}
51 | )
52 | const reader = res.body?.getReader()
53 |
54 | if (reader) {
55 | return streamResponse(reader, onProgress)
56 | } else {
57 | return false
58 | }
59 | }
60 |
61 | export async function translate(srtData: string, onProgress: ProgressCallback) {
62 | const res = await fetch(`/api/translate`, {
63 | method: 'POST',
64 | headers: {
65 | 'Content-Type': 'text/plain; charset=utf-8'
66 | },
67 | body: srtData
68 | })
69 | const reader = res.body?.getReader()
70 |
71 | if (reader) {
72 | const result = await streamResponse(reader, onProgress)
73 | return result
74 | .split('\n')
75 | .filter(line => {
76 | return !line.startsWith('[Error]')
77 | })
78 | .join('\n')
79 | } else {
80 | return false
81 | }
82 | }
83 |
84 | async function streamResponse(
85 | reader: ReadableStreamDefaultReader,
86 | onProgress: ProgressCallback
87 | ): Promise {
88 | return await new Promise(resolve => {
89 | const decoder = new TextDecoder()
90 | let result = ''
91 | const readChunk = ({
92 | done,
93 | value
94 | }: ReadableStreamReadResult) => {
95 | if (done) {
96 | resolve(result)
97 | return
98 | }
99 |
100 | const output = decoder.decode(value)
101 | result += output
102 | onProgress(output)
103 | reader.read().then(readChunk)
104 | }
105 |
106 | reader.read().then(readChunk)
107 | })
108 | }
109 |
--------------------------------------------------------------------------------
/components/video-form.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import * as Form from '@radix-ui/react-form'
3 | import { styled } from '@stitches/react'
4 |
5 | type Props = {
6 | onSubmit: (videoUrl: string) => void
7 | isProcessing: boolean
8 | }
9 |
10 | export const VideoForm: React.FC = ({ onSubmit, isProcessing }) => {
11 | const handleSubmit: React.FormEventHandler = e => {
12 | e.preventDefault()
13 |
14 | const videoUrl = (e.target as HTMLFormElement | undefined)?.videoUrl
15 | ?.value as string
16 | onSubmit(videoUrl)
17 | }
18 | return (
19 |
20 |
21 |
22 | Video URL
23 |
24 | Please enter a video URL
25 |
26 |
27 | Please provide a valid URL
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
43 |
44 |
45 | )
46 | }
47 |
48 | const FormRoot = styled(Form.Root, {})
49 |
50 | const FormField = styled(Form.Field, {
51 | display: 'grid',
52 | marginBottom: 10
53 | })
54 |
55 | const FormLabel = styled(Form.Label, {
56 | fontSize: 15,
57 | fontWeight: 500,
58 | lineHeight: '35px',
59 | color: '$foreground'
60 | })
61 |
62 | const FormMessage = styled(Form.Message, {
63 | fontSize: 13,
64 | color: '$red600',
65 | opacity: 0.8
66 | })
67 |
68 | const Flex = styled('div', { display: 'flex' })
69 |
70 | const inputStyles = {
71 | all: 'unset',
72 | boxSizing: 'border-box',
73 | width: '100%',
74 | display: 'inline-flex',
75 | alignItems: 'center',
76 | justifyContent: 'center',
77 | borderRadius: 4,
78 |
79 | fontSize: 15,
80 | color: '$foreground',
81 | backgroundColor: '$gray300',
82 | boxShadow: `0 0 0 1px $gray400`,
83 | '&:hover': { boxShadow: `0 0 0 1px $gray600` },
84 | '&:focus': { boxShadow: `0 0 0 2px $purple600` },
85 | '&::selection': { backgroundColor: '$gray600', color: 'white' }
86 | }
87 |
88 | const Input = styled('input', {
89 | ...inputStyles,
90 | height: 35,
91 | lineHeight: 1,
92 | padding: '0 10px'
93 | })
94 |
95 | const Button = styled('button', {
96 | all: 'unset',
97 | boxSizing: 'border-box',
98 | display: 'inline-flex',
99 | alignItems: 'center',
100 | justifyContent: 'center',
101 | borderRadius: 4,
102 | padding: '0 15px',
103 | fontSize: 15,
104 | lineHeight: 1,
105 | fontWeight: 500,
106 | height: 35,
107 | width: '100%',
108 |
109 | '&:disabled': {
110 | opacity: 0.5
111 | },
112 | backgroundColor: '$purple500',
113 | color: 'white',
114 | boxShadow: `0 2px 10px $gray400`,
115 | '&:not(:disabled):hover': { backgroundColor: '$purple600' },
116 | '&:not(:disabled):focus': { boxShadow: `0 0 0 2px black` }
117 | })
118 |
--------------------------------------------------------------------------------