├── .eslintrc.json ├── .example.env ├── .github └── FUNDING.yml ├── .gitignore ├── README copy.md ├── README-CN.md ├── README.md ├── components ├── Footer.tsx ├── Github.tsx ├── Header.tsx ├── SubtitleLine.tsx ├── Subtitles.tsx ├── new │ ├── FooterNew.tsx │ ├── HeaderNew.tsx │ ├── SubtitleLineNew.tsx │ └── SubtitlesNew.tsx └── srt │ ├── About.tsx │ ├── Setting.tsx │ ├── SrtMain.tsx │ └── SrtNew.tsx ├── jest.config.js ├── jsconfig.json ├── lib ├── __tests__ │ └── bilibili.test.ts ├── ass_to_srt.ts ├── lang.ts ├── lemon.ts ├── openai │ ├── OpenAIResult.ts │ ├── openai.ts │ └── prompt.ts └── srt.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── googleTran.ts │ ├── proxy │ │ └── [...slug].js │ └── translate.ts ├── index.tsx └── srt.tsx ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── loading.svg ├── locales │ ├── en │ │ └── common.json │ └── zh-CN │ │ └── common.json ├── next.svg ├── openai.png ├── set1.png ├── setting_1.png ├── thirteen.svg └── vercel.svg ├── sentry.client.config.js ├── sentry.edge.config.js ├── sentry.properties ├── sentry.server.config.js ├── styles ├── Home.module.css ├── Srt.module.css ├── SubtitleLine.module.css └── globals.css ├── tsconfig.json └── utils ├── constants.ts ├── editTableHook.tsx ├── env.ts ├── fp.ts └── themeConfig.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | # OPENAI_API_KEY=sk-TWx3jZeELAi7bRCvFe754a9e98Bb43C4A1957f4cA25a20Ca 2 | # BILIBILI_SESSION_TOKEN= 3 | # SAVESUBS_X_AUTH_TOKEN= 4 | # UPSTASH_REDIS_REST_URL= 5 | # UPSTASH_REDIS_REST_TOKEN= 6 | # UPSTASH_RATE_REDIS_REST_URL= 7 | # UPSTASH_RATE_REDIS_REST_TOKEN= 8 | # NEXT_PUBLIC_SENTRY_DSN= 9 | # DEFAULT_OPENAI_API_KEY=sk-TWx3jZeELAi7bRCvFe754a9e98Bb43C4A1957f4cA25a20Ca 10 | # DEFAULT_OPENAI_BASE_HOST=https://api.openai.com -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ["https://www.buymeacoffee.com/cgsv"] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.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 | 38 | # Sentry 39 | .sentryclirc 40 | 41 | # Sentry 42 | next.config.original.js 43 | -------------------------------------------------------------------------------- /README copy.md: -------------------------------------------------------------------------------- 1 | **[中文](./README-CN.md) | English** 2 | 3 | # 🤖 AI Subtitle Translation [ai.cgsv.top](https://ai.cgsv.top/en) 4 | 5 | The main function is to translate local subtitle files or Bilibili/YouTube subtitles into the language of your choice using GPT-3.5 as the translation engine. 6 | 7 | ## Function Details 8 | 9 | - Support uploading local SRT/ASS format subtitle files and grabbing Bilibili/YouTube subtitles 10 | - Translate part of the text and display the translation in real-time: 11 | - Support exporting original/translated subtitles to local (currently only support SRT format) 12 | - Translation engine supports GPT-3.5 or Google Translate 13 | - Support mutual translation of all common languages 14 | 15 | 16 | ![AI Subtitle Translation](./public/aisub_en.png) 17 | 18 | ## How it works 19 | 20 | - Using [OpenAI GPT-3.5 API](https://openai.com/api/) as the translation engine 21 | - Developed with [NextJS](https://nextjs.org/) and deployed on [Vercel](https://vercel.com/) with [Vercel Edge functions](https://vercel.com/features/edge-functions) 22 | - Using [Upstash](https://console.upstash.com/) Redis for caching and rate limiting 23 | 24 | ## Notices 25 | 26 | - Please try to use your own OpenAI key for more stability (this project will not store user keys) 27 | - Translating complete subtitle files requires a large number of tokens, please pay attention to token usage 28 | - Translating complete subtitle files may take a long time, please do not close the current browser window 29 | 30 | ## Run locally 31 | 32 | After copying this project to your local machine, create your own .env file based on the .example.env file and complete the required environment variables. 33 | 34 | Then, run the following command in the terminal. Once successful, you can preview the project at http://localhost:3000. 35 | 36 | ```bash 37 | npm run dev 38 | ``` 39 | 40 | ## One-Click Deploy 41 | 42 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/cgsvv/AISubtitle&env=OPENAI_API_KEY,UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN,UPSTASH_RATE_REDIS_REST_URL,UPSTASH_RATE_REDIS_REST_TOKEN&project-name=ai-subtitle&repo-name=ai-subtitle) 43 | 44 | ## Contact Me 45 | 46 | Email: cgsv@qq.com 47 | 48 |
49 |

Support

50 | 51 |
52 | 53 | 54 |
55 |
56 | 57 | Buy Me A Coffee 58 | 59 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | **[English](./README.md) | 中文** 2 | # 🤖 AI Subtitle · AI字幕翻译 [ai.cgsv.top](https://ai.cgsv.top/zh-CN) 3 | 4 | 主要功能是将本地字幕文件或者B站/油管字幕翻译成你想要的语言,使用GPT-3.5作为翻译引擎。 5 | 6 | 7 | ## 功能详情 8 | - 支持上传本地SRT/ASS格式字幕文件,以及抓取B站/油管字幕 9 | - 翻译部分文本,实时查看翻译结果 10 | - 支持导出原文/译文字幕到本地(目前支持导出SRT格式) 11 | - 翻译引擎支持GPT-3.5或Google Translate 12 | - 支持所有常见语言的互译 13 | 14 | ![AI字幕翻译](./public/aisub_zh.png) 15 | 16 | ## 工作原理 17 | 18 | - 使用[OpenAI GPT-3.5 API](https://openai.com/api/)作为翻译引擎 19 | - 使用[NextJS](https://nextjs.org/)和[Vercel Edge functions](https://vercel.com/features/edge-functions) 开发,并在[Vercel](https://vercel.com/)部署 20 | - 使用[Upstash](https://console.upstash.com/) Redis做缓存和限流 21 | 22 | ## 注意事项 23 | 24 | - 请尽量使用自己OpenAI key,会更加稳定(本项目不会存储用户的key) 25 | - 翻译完整字幕文件需要较多token,请注意token用量 26 | - 翻译完整字幕文件可能耗时较长,请不要关闭当前浏览器窗口 27 | 28 | ## 本地运行 29 | 30 | 复制本项目到本地后,参考.example.env文件创建一个自己的.env,补全需要的环境变量 31 | 32 | 然后,在命令行运行如下命令,成功后可在 `http://localhost:3000` 预览 33 | 34 | ```bash 35 | npm run dev 36 | ``` 37 | 38 | ## 部署到Vercel 39 | 40 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/cgsvv/AISubtitle&env=OPENAI_API_KEY,UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN,UPSTASH_RATE_REDIS_REST_URL,UPSTASH_RATE_REDIS_REST_TOKEN&project-name=ai-subtitle&repo-name=ai-subtitle) 41 | 42 | ## 联系 43 | 44 | Email: cgsv@qq.com 45 | 46 | ## 支持 47 | 48 |
49 | 50 | 51 |
52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - 项目基于[AISubtitle](https://github.com/cgsvv/AISubtitle) 开发 2 | 3 | # 新增 4 | 5 | - [x] 自定义`Base Host` 6 | - [x] 自定义`模型` 7 | - [x] 自定义`prompt` 8 | 9 | # 使用申明 10 | 11 | 该项目改为本地`直连`Api 地址,如果使用中转和反代,请检查跨域问题。 12 | 13 | # 使用步骤 14 | 15 | ## 1、情况说明 16 | 17 | #### a、自有 Api key 和 【本地环境】支持的 18 | 19 | - 把下图 2 设置为: `sk-xxxxxxxx` 20 | - 把下图 1 设置为: `https://api.openai.com` 21 | 22 | #### b、有自建反代的 23 | 24 | - 把下图 1 设置为你的地址: `https://xxxx.xxxxx.com` 25 | 26 | #### c、中转的 27 | 28 | - 把下图 2 设置为`中转`的 key: `xxxxxxxx` 29 | - 把下图 1 设置为`中转`的地址: `https://xxxx.xxxxx.com` 30 | 31 |
32 | 33 |
34 | 35 | ## 2、推荐设置 36 | 37 | 如果你想翻译快点,可以把分页大小设置大点,然后把模型改为`16k`版本,如果你有`gpt-4`权限,可直接切换至`gpt-4`模型使用 38 | 39 | 40 | *** 41 | 42 | - The project is developed based on [AISubtitle](https://github.com/cgsvv/AISubtitle) 43 | 44 | # Additions 45 | 46 | - [x] Customizable `Base Host` 47 | - [x] Customizable `Model` 48 | - [x] Customizable `Prompt` 49 | 50 | # Usage Declaration 51 | 52 | This project has been switched to a local `Direct Connect` API address. If using a proxy or reverse proxy, please check for cross-origin issues. 53 | 54 | # Usage Steps 55 | 56 | ## 1. Situation Description 57 | 58 | #### a. For those with their own Api key and support for 【Local Environment】: 59 | 60 | - Set the setting in image 2 to: `sk-xxxxxxxx` 61 | - Set the setting in image 1 to: `https://api.openai.com` 62 | 63 | #### b. For those with their own reverse proxy: 64 | 65 | - Set the setting in image 1 to your address: `https://xxxx.xxxxx.com` 66 | 67 | #### c. For those using an intermediary: 68 | 69 | - Set the setting in image 2 to the intermediary's key: `xxxxxxxx` 70 | - Set the setting in image 1 to the intermediary's address: `https://xxxx.xxxxx.com` 71 | 72 |
73 | 74 |
75 | 76 | ## 2. Recommended Settings 77 | 78 | To speed up translations, you can increase the pagination size and switch the model to the `16k` version. If you have access to `gpt-4`, you can directly switch to using the `gpt-4` model. 79 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function Footer() { 4 | return ( 5 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/Github.tsx: -------------------------------------------------------------------------------- 1 | export default function Github({ 2 | width = "36", 3 | height = "36", 4 | }: { 5 | width: string; 6 | height: string; 7 | }) { 8 | return ( 9 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Github from "./Github"; 3 | import { useLocalStorage } from "react-use"; 4 | import { checkOpenaiApiKey } from "@/lib/openai/openai"; 5 | import { toast } from "react-hot-toast"; 6 | import { useEffect, useState } from "react"; 7 | import { CacheKey } from "@/utils/constants"; 8 | 9 | export default function Header() { 10 | const [userKey, setUserKey] = useLocalStorage( 11 | CacheKey.UserApikeyWithOpenAi 12 | ); 13 | const [userHost, setUserHost] = useLocalStorage( 14 | CacheKey.UserBaseHostWithOpenAi 15 | ); 16 | 17 | // note: Hydration error when use SSR, do not use localStorage for display now 18 | const [translateEngine, setTranslateEngine] = useState(""); 19 | const [translateEngineStore, setTranslateEngineStore] = 20 | useLocalStorage("translate-engine"); 21 | 22 | const tooltip = 23 | "当前使用: " + 24 | (translateEngine === "google" ? "google翻译" : "ChatGPT") + 25 | ", 点击切换"; 26 | 27 | useEffect(() => { 28 | setTranslateEngine(translateEngineStore || ""); 29 | }, [translateEngineStore]); 30 | 31 | /** 32 | * 切换翻译引擎 33 | */ 34 | const changeEngine = () => { 35 | const newEngine = translateEngine === "google" ? "openai" : "google"; 36 | //setTranslateEngine(newEngine); 37 | setTranslateEngineStore(newEngine); 38 | }; 39 | 40 | /** 41 | * 设置OpenAIKey 42 | */ 43 | const setOpenAIKey = () => { 44 | const key = prompt("你的Key"); 45 | if (key && checkOpenaiApiKey(key)) { 46 | setUserKey(key); 47 | toast.success("ApiKey 设置成功"); 48 | } else { 49 | toast.error("ApiKey 设置失败"); 50 | } 51 | }; 52 | 53 | /** 54 | * 设置OpenAi代理 55 | */ 56 | const setOpenAIBaseHost = () => { 57 | const baseHost = prompt("你的代理"); 58 | if (baseHost) { 59 | setUserHost(baseHost); 60 | toast.success("代理地址 设置成功"); 61 | } else { 62 | toast.error("代理地址 设置失败"); 63 | } 64 | }; 65 | 66 | return ( 67 |
76 |
77 | 83 | 84 | 85 | settings 94 |
95 |
96 | {/* settings */} 105 | settings 114 | 115 | settings 123 |
124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /components/SubtitleLine.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/SubtitleLine.module.css"; 2 | 3 | export default function SubTitleLine({ 4 | timeStamp, 5 | content, 6 | translation, 7 | }: { 8 | timeStamp: string; 9 | content: string; 10 | translation?: string; 11 | }) { 12 | timeStamp = timeStamp.trim().slice(0, 12); 13 | return ( 14 |
15 |
{timeStamp}
16 |
{content}
17 | {/* always show translation for now 18 | translation != undefined && */} 19 |
{translation || ""}
20 | {/* */} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/Subtitles.tsx: -------------------------------------------------------------------------------- 1 | import SubTitleLine from "./SubtitleLine"; 2 | import { Node } from "@/lib/srt"; 3 | 4 | export default function Subtitles({ 5 | nodes, 6 | transNodes, 7 | }: { 8 | nodes: Node[]; 9 | transNodes?: Node[]; 10 | }) { 11 | return ( 12 |
15 | 20 | {nodes.map((node, index) => { 21 | let transText; 22 | if (transNodes && transNodes.length > 0) { 23 | transText = ""; 24 | if (transNodes[index]) transText = transNodes[index].content; 25 | } 26 | return ( 27 | 33 | ); 34 | })} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /components/new/FooterNew.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Row } from "antd"; 2 | 3 | export default function Footer() { 4 | return ( 5 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/new/HeaderNew.tsx: -------------------------------------------------------------------------------- 1 | import Github from "../Github"; 2 | import { Col, Row } from "antd"; 3 | 4 | export default function Header() { 5 | return ( 6 | <> 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/new/SubtitleLineNew.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from "react"; 2 | import type { InputRef } from "antd"; 3 | import { Button, Form, Input, Popconfirm, Table } from "antd"; 4 | import { ColumnTypes, EditableCell, EditableRow } from "@/utils/editTableHook"; 5 | 6 | const Test: React.FC = () => { 7 | // //监听dataSource状态 8 | // const [dataSource, setDataSource] = useState([ 9 | // { 10 | // key: "0", 11 | // name: "Edward King 0", 12 | // age: "32", 13 | // address: "London, Park Lane no. 0", 14 | // }, 15 | // { 16 | // key: "1", 17 | // name: "Edward King 1", 18 | // age: "32", 19 | // address: "London, Park Lane no. 1", 20 | // }, 21 | // ]); 22 | 23 | // //删除 24 | // const handleDelete = (key: React.Key) => { 25 | // const newData = dataSource.filter((item) => item.key !== key); 26 | // setDataSource(newData); 27 | // }; 28 | 29 | // //列 30 | // const defaultColumns: (ColumnTypes[number] & { 31 | // editable?: boolean; 32 | // dataIndex: string; 33 | // })[] = [ 34 | // { 35 | // title: "name", 36 | // dataIndex: "name", 37 | // // width: "30%", 38 | // editable: true, 39 | // }, 40 | // { 41 | // title: "age", 42 | // dataIndex: "age", 43 | // }, 44 | // { 45 | // title: "address", 46 | // dataIndex: "address", 47 | // }, 48 | // { 49 | // title: "operation", 50 | // dataIndex: "operation", 51 | // render: (_, record: { key: React.Key }) => 52 | // dataSource.length >= 1 ? ( 53 | // handleDelete(record.key)} 56 | // > 57 | // Delete 58 | // 59 | // ) : null, 60 | // }, 61 | // ]; 62 | 63 | // const handleAdd = () => { 64 | // console.log("data", dataSource); 65 | // }; 66 | 67 | // const handleSave = (row: any) => { 68 | // const newData = [...dataSource]; 69 | // const index = newData.findIndex((item) => row.key === item.key); 70 | // const item = newData[index]; 71 | // newData.splice(index, 1, { 72 | // ...item, 73 | // ...row, 74 | // }); 75 | // setDataSource(newData); 76 | // }; 77 | 78 | // const components = { 79 | // body: { 80 | // row: EditableRow, 81 | // cell: EditableCell, 82 | // }, 83 | // }; 84 | 85 | // const columns = defaultColumns.map((col) => { 86 | // if (!col.editable) { 87 | // return col; 88 | // } 89 | // return { 90 | // ...col, 91 | // onCell: (record: any) => ({ 92 | // record, 93 | // editable: col.editable, 94 | // dataIndex: col.dataIndex, 95 | // title: col.title, 96 | // handleSave, 97 | // }), 98 | // }; 99 | // }); 100 | 101 | return ( 102 |
103 | {/* 106 | "editable-row"} 109 | bordered 110 | dataSource={dataSource} 111 | columns={columns as ColumnTypes} 112 | /> */} 113 | 114 | ); 115 | }; 116 | 117 | export default Test; 118 | -------------------------------------------------------------------------------- /components/new/SubtitlesNew.tsx: -------------------------------------------------------------------------------- 1 | import { Node } from "@/lib/srt"; 2 | import { EditableCell, EditableRow } from "@/utils/editTableHook"; 3 | import { Table } from "antd"; 4 | import React, { useEffect, useState } from "react"; 5 | 6 | /** 7 | * 新增行 8 | */ 9 | export default function SubtitlesNew({ 10 | nodes, 11 | transNodes, 12 | onUpdateNode, 13 | }: { 14 | nodes: Node[]; 15 | transNodes?: Node[]; 16 | onUpdateNode: (updatedNodeItem: Node) => void; 17 | }) { 18 | const [dataSource, setDataSource] = useState([]); 19 | 20 | // const tempDataSource = nodes.map((it) => { 21 | // const currentTransNodes = transNodes?.filter((item) => item.pos == it.pos); 22 | // let transContext = ""; 23 | // if (currentTransNodes?.length == 1) { 24 | // transContext = currentTransNodes[0].content; 25 | // } 26 | // return { 27 | // key: it.pos, 28 | // timestamp: it.timestamp?.trim().slice(0, 12), 29 | // sourceContext: it.content, 30 | // transContext: transContext, 31 | // }; 32 | // }); 33 | 34 | useEffect(() => { 35 | //生成dataSoucre 36 | const tempDataSource = nodes.map((it) => { 37 | const currentTransNodes = transNodes?.filter( 38 | (item) => item.pos === it.pos 39 | ); 40 | let transContext = ""; 41 | if (currentTransNodes?.length === 1) { 42 | transContext = currentTransNodes[0].content; 43 | } 44 | 45 | return { 46 | key: it.pos, 47 | timestamp: it.timestamp?.trim().slice(0, 12), 48 | sourceContext: it.content, 49 | transContext: transContext, 50 | }; 51 | }); 52 | setDataSource(tempDataSource); 53 | }, [nodes, transNodes]); 54 | 55 | const defaultColumns: any[] = [ 56 | { 57 | title: "序号", 58 | dataIndex: "key", 59 | key: "key", 60 | width: 100, 61 | }, 62 | { 63 | title: "时间戳", 64 | dataIndex: "timestamp", 65 | key: "timestamp", 66 | width: 150, 67 | }, 68 | { 69 | title: "原文", 70 | dataIndex: "sourceContext", 71 | key: "sourceContext", 72 | // width: 200, 73 | // ellipsis: true, 74 | }, 75 | { 76 | title: "译文", 77 | dataIndex: "transContext", 78 | key: "transContext", 79 | editable: true, 80 | // width: 200, 81 | // ellipsis: true, 82 | }, 83 | ]; 84 | 85 | //编辑 需要的组件 86 | const components = { 87 | body: { 88 | row: EditableRow, 89 | cell: EditableCell, 90 | }, 91 | }; 92 | const handleSave = (row: any) => { 93 | const newData = [...dataSource]; 94 | const index = newData.findIndex((item) => row.key === item.key); 95 | const item = newData[index]; 96 | newData.splice(index, 1, { 97 | ...item, 98 | ...row, 99 | }); 100 | setDataSource(newData); 101 | 102 | //回调 103 | if (onUpdateNode) { 104 | onUpdateNode({ 105 | pos: row.key, 106 | content: row.transContext, 107 | } as Node); 108 | } 109 | }; 110 | 111 | const editColumns = defaultColumns.map((col) => { 112 | if (!col.editable) { 113 | return col; 114 | } 115 | 116 | return { 117 | ...col, 118 | onCell: (record: any) => ({ 119 | record, 120 | editable: col.editable, 121 | dataIndex: col.dataIndex, 122 | title: col.title, 123 | handleSave, 124 | }), 125 | }; 126 | }); 127 | 128 | return ( 129 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /components/srt/About.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Space } from "antd"; 3 | const About: React.FC = () => { 4 | return ( 5 | 10 |
18 | *** 19 | 注:尽量使用自建代理或者社区公开代理,如需直连请自行本地准备好【环境】 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default About; 26 | -------------------------------------------------------------------------------- /components/srt/Setting.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLocalStorage } from "react-use"; 3 | import { 4 | Button, 5 | Form, 6 | Input, 7 | message, 8 | Select, 9 | Space, 10 | Slider, 11 | InputNumber, 12 | } from "antd"; 13 | import { 14 | CacheKey, 15 | CustomConfigItem, 16 | getCustomConfigCache, 17 | } from "@/utils/constants"; 18 | const { TextArea } = Input; 19 | 20 | const Setting: React.FC = () => { 21 | const [setting, setCustomSetting] = useLocalStorage( 22 | CacheKey.UserCustomSetting 23 | ); 24 | const [form] = Form.useForm(); 25 | const formItemLayout = { labelCol: { span: 4 }, wrapperCol: { span: 14 } }; 26 | const buttonItemLayout = { wrapperCol: { span: 14, offset: 4 } }; 27 | const modleOptions = [ 28 | "gpt-3.5-turbo", 29 | "gpt-3.5-turbo-16k", 30 | "gpt-3.5-turbo-1106", 31 | "gpt-4", 32 | "gpt-4-0613", 33 | "gpt-4-1106-preview", 34 | "gpt-4o", 35 | "gpt-4o-2024-05-13", 36 | "chatglm_lite", 37 | "chatglm_std", 38 | "chatglm_pro", 39 | ]; 40 | 41 | // type FieldType = { 42 | // customHost?: string; 43 | // customModel?: string; 44 | // apiKey?: string; 45 | // promptTemplate?: string; 46 | // customPageSize: Number; 47 | // }; 48 | 49 | let defaultValue = getCustomConfigCache(); 50 | 51 | //校验成功 52 | const onFinish = (values: any) => { 53 | setCustomSetting(values); 54 | message.success("操作成功,前往翻译吧"); 55 | //console.log("Success:", values); 56 | }; 57 | 58 | const onClear = () => { 59 | localStorage.removeItem(CacheKey.UserCustomSetting); 60 | message.success("重置成功,请刷新页面"); 61 | }; 62 | return ( 63 | 68 |
75 | label="代理地址" name="customHost"> 76 | 77 | 78 | label="ApiKey" name="apiKey"> 79 | 80 | 81 | label="延迟" name="delaySecond"> 82 | 83 | 84 | 85 | label="模型" name="customModel"> 86 | 527 | * 非必要可不用改 528 | 529 |
530 | 535 | {"选择字幕文件"} 536 | 542 | 543 | 544 | 547 | 554 |
555 |
556 | 563 |

570 | {curPage + 1} / {Math.ceil(nodes.length / PAGE_SIZE)} 571 |

572 | 579 | 580 | 583 | 590 | setShowAllLang(e.target.checked)} 596 | > 597 | {!loading ? ( 598 | 607 | ) : ( 608 | 629 | )} 630 |
631 |
632 | {filename ? filename : "未选择字幕"} 633 |
634 | 638 |
647 | {!transFileStatus.isTranslating ? ( 648 | 659 | ) : ( 660 | 679 | )} 680 | 687 | 694 | 695 | 702 |
703 | 704 | 705 | 706 | 707 | ); 708 | } 709 | -------------------------------------------------------------------------------- /components/srt/SrtNew.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Button, 4 | Dropdown, 5 | MenuProps, 6 | message, 7 | Modal, 8 | Popconfirm, 9 | Select, 10 | Space, 11 | Switch, 12 | Tooltip, 13 | Upload, 14 | UploadProps, 15 | Slider, 16 | Row, 17 | Col, 18 | } from "antd"; 19 | import { 20 | getEncoding, 21 | parseSrt, 22 | Node, 23 | nodesToSrtText, 24 | checkIsSrtFile, 25 | nodesToTransNodes, 26 | convertToSrt, 27 | } from "@/lib/srt"; 28 | import { commonLangZh, suportedLangZh } from "@/lib/lang"; 29 | import { 30 | DEFAULT_BASE_URL_HOST, 31 | getCustomConfigCache, 32 | openAiErrorCode, 33 | } from "@/utils/constants"; 34 | import { isDev } from "@/utils/env"; 35 | import { getPayload, parse_gpt_resp } from "@/lib/openai/prompt"; 36 | import { OpenAIResult } from "@/lib/openai/OpenAIResult"; 37 | import SubtitlesNew from "@/components/new/SubtitlesNew"; 38 | 39 | const MAX_FILE_SIZE = 512 * 1024; // 512KB 40 | //const PAGE_SIZE = 20; 41 | const MAX_RETRY = 5; 42 | 43 | /** 44 | * 转换文件时的状态 45 | */ 46 | type TranslateFileStatus = { 47 | isTranslating: boolean; 48 | transCount: number; 49 | }; 50 | 51 | const downItems = [ 52 | { 53 | key: "downTrans", 54 | label: "下载译文", 55 | }, 56 | { 57 | key: "downTransAndoriginal", 58 | label: "下载双语字幕", 59 | }, 60 | ]; 61 | 62 | //sleep 毫秒 63 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 64 | 65 | /** 66 | * 下载文件 67 | * @param filename 文件名 68 | * @param text 文本内容 69 | */ 70 | function download(filename: string, text: string) { 71 | var element = document.createElement("a"); 72 | element.setAttribute( 73 | "href", 74 | "data:text/plain;charset=utf-8," + encodeURIComponent(text) 75 | ); 76 | element.setAttribute("download", filename); 77 | 78 | element.style.display = "none"; 79 | document.body.appendChild(element); 80 | 81 | element.click(); 82 | 83 | document.body.removeChild(element); 84 | } 85 | 86 | /** 87 | * 按照节点列表翻译 88 | * @param nodes 待翻译节点列表 89 | * @param targetLang 目标语言 90 | * @returns 91 | */ 92 | async function translate_one_batch(nodes: Node[], targetLang: string) { 93 | const sentences = nodes.map((node) => node.content); 94 | // if last sentence ends with ",", remove it 95 | const lastSentence = sentences[sentences.length - 1]; 96 | if (lastSentence.endsWith(",") || lastSentence.endsWith(",")) { 97 | sentences[sentences.length - 1] = lastSentence.substring( 98 | 0, 99 | lastSentence.length - 1 100 | ); 101 | } 102 | 103 | const currentConfig = getCustomConfigCache(); 104 | //获取请求体 105 | const payload = getPayload( 106 | sentences, 107 | targetLang, 108 | undefined, 109 | currentConfig.promptTemplate, 110 | currentConfig.customModel 111 | ); 112 | 113 | const { res_keys } = payload; 114 | 115 | try { 116 | isDev && currentConfig.apiKey && console.log("=====use user api key====="); 117 | isDev && 118 | currentConfig.customHost && 119 | console.log("=====use user custom host====="); 120 | isDev && console.log("payload", payload); 121 | const result = await OpenAIResult( 122 | payload, 123 | currentConfig.apiKey, 124 | currentConfig.customHost ?? DEFAULT_BASE_URL_HOST 125 | ); 126 | const resp = parse_gpt_resp(result, res_keys!); 127 | return nodesToTransNodes(nodes, resp); 128 | } catch (error: any) { 129 | console.log("API error", error); 130 | const tempError = JSON.parse(error.message); 131 | throw new Error(`翻译异常,【${(openAiErrorCode as any)[tempError.code]}】`); 132 | } 133 | } 134 | 135 | const SrtNew: React.FC = () => { 136 | const [PAGE_SIZE, setPageSize] = useState(5); 137 | const [nodes, setNodes] = useState([]); 138 | const [transNodes, setTransNodes] = useState([]); // make transNode the same structure as nodes 139 | const [curPage, setCurPage] = useState(0); 140 | const [lang, setLang] = useState("中文"); 141 | const [loading, setLoading] = useState(false); 142 | const [transFileStatus, setTransFileStatus] = useState({ 143 | isTranslating: false, 144 | transCount: 0, 145 | }); 146 | const [showAllLang, setShowAllLang] = useState(false); 147 | const langs = showAllLang ? suportedLangZh : commonLangZh; 148 | 149 | /** 150 | * 翻译文件 151 | * @param nodes 待翻译节点列表 152 | * @param lang 目标语言 153 | * @param notifyResult 翻译完 结果通知回调 154 | * @returns 155 | */ 156 | async function traslate_file( 157 | nodes: Node[], 158 | lang: string, 159 | notifyResult?: any 160 | ) { 161 | const batches: Node[][] = []; 162 | for (let i = 0; i < nodes.length; i += PAGE_SIZE) { 163 | batches.push(nodes.slice(i, i + PAGE_SIZE)); 164 | } 165 | // for now, just use sequential execution 166 | const results: Node[] = []; 167 | let batch_num = 0; 168 | const currentConfig = getCustomConfigCache(); 169 | 170 | for (const batch of batches) { 171 | let success = false; 172 | for (let i = 0; i < MAX_RETRY && !success; i++) { 173 | try { 174 | const r = await translate_one_batch(batch, lang); 175 | results.push(...r); 176 | success = true; 177 | if (notifyResult) { 178 | notifyResult(batch_num, r); 179 | } 180 | console.log(`Translated ${results.length} of ${nodes.length}`); 181 | 182 | if (currentConfig.delaySecond && currentConfig.delaySecond > 0) { 183 | console.log( 184 | "已设置延迟,开始延迟" + currentConfig.delaySecond + "s" 185 | ); 186 | //延迟 满足普通Api 187 | await sleep(currentConfig.delaySecond * 1000); 188 | } 189 | } catch (e) { 190 | console.error(e); 191 | //异常延迟3s 192 | await sleep(3000); 193 | } 194 | } 195 | batch_num++; 196 | if (!success) { 197 | console.error(`translate_all failed for ${batch}`); 198 | throw new Error(`translate file ${batch} failed`); 199 | } 200 | } 201 | return results; 202 | } 203 | 204 | /** 205 | * 根据页数获取当前页待翻译列表 206 | * @param nodes 所有节点 207 | * @param curPage 当前页 208 | * @returns 209 | */ 210 | function curPageNodes(nodes: Node[], curPage: number) { 211 | let res = nodes.slice(curPage * PAGE_SIZE, (curPage + 1) * PAGE_SIZE); 212 | if (res.findIndex((n) => n) === -1) { 213 | res = []; 214 | } 215 | return res; 216 | } 217 | 218 | /** 219 | * 添加字幕并渲染 220 | * @param text 文本内容 221 | * @returns 222 | */ 223 | const onNewSubtitleText = (text: string) => { 224 | if (!checkIsSrtFile(text)) { 225 | const converted = convertToSrt(text); 226 | if (converted) { 227 | text = converted; 228 | } else { 229 | message.error("转为Srt失败,请重新选择文件"); 230 | return; 231 | } 232 | } 233 | const nodes = parseSrt(text); 234 | setNodes(nodes); 235 | setTransNodes([]); 236 | setCurPage(0); 237 | }; 238 | 239 | //Upload配置 240 | const props: UploadProps = { 241 | maxCount: 1, 242 | name: "file", 243 | accept: ".srt,.ass,.txt", 244 | beforeUpload: async function (file) { 245 | const f: File = file; 246 | if (!f) return; 247 | if (f.size > MAX_FILE_SIZE) { 248 | message.error("最大只支持大小512kb"); 249 | return Upload.LIST_IGNORE; 250 | } 251 | 252 | const encoding = await getEncoding(f); 253 | if (!encoding) { 254 | message.error("无法以文本打开"); 255 | return Upload.LIST_IGNORE; 256 | } 257 | 258 | const data = await f.arrayBuffer(); 259 | let text = new TextDecoder(encoding!).decode(data); 260 | // onNewSubtitleText(text, f.name); 261 | // console.log("file", file); 262 | // console.log("txet", text); 263 | onNewSubtitleText(text); 264 | return false; 265 | }, 266 | }; 267 | 268 | //翻页 269 | const toPage = (delta: number) => { 270 | const newPage = curPage + delta; 271 | if (newPage < 0 || newPage >= nodes.length / PAGE_SIZE) return; 272 | setCurPage(newPage); 273 | }; 274 | 275 | //语言选择 276 | const onLangChange = (value: string) => { 277 | setLang(value); 278 | }; 279 | 280 | /** 281 | * 翻译当前页面 282 | */ 283 | const onTranslatePage = async () => { 284 | if (nodes.length == 0) { 285 | message.warning("无翻译内容,请选择文件"); 286 | return; 287 | } 288 | 289 | setLoading(true); 290 | setTransFileStatus({ isTranslating: true, transCount: 0 }); 291 | try { 292 | const newnodes = await translate_one_batch( 293 | curPageNodes(nodes, curPage), 294 | lang 295 | ); 296 | setTransNodes((nodes) => { 297 | const nodesCopy = [...nodes]; 298 | for (let i = 0; i < PAGE_SIZE; i++) { 299 | nodesCopy[curPage * PAGE_SIZE + i] = newnodes[i]; 300 | } 301 | return nodesCopy; 302 | }); 303 | } catch (e: any) { 304 | console.error("translate failed", e); 305 | message.error("翻译失败" + String(e.message)); 306 | } finally { 307 | setLoading(false); 308 | setTransFileStatus({ isTranslating: false, transCount: 0 }); 309 | } 310 | }; 311 | 312 | //文件翻译时 翻译完回调 313 | const on_trans_result = (batch_num: number, tnodes: Node[]) => { 314 | setTransFileStatus((old) => { 315 | return { ...old, transCount: batch_num + 1 }; 316 | }); 317 | setTransNodes((nodes) => { 318 | const nodesCopy = [...nodes]; 319 | for (let i = 0; i < PAGE_SIZE; i++) { 320 | nodesCopy[batch_num * PAGE_SIZE + i] = tnodes[i]; 321 | } 322 | return nodesCopy; 323 | }); 324 | }; 325 | 326 | /** 327 | * 翻译整个文件 328 | */ 329 | const onTranslateFile = async () => { 330 | if (nodes.length == 0) { 331 | message.warning("无翻译内容,请选择文件"); 332 | return; 333 | } 334 | 335 | setLoading(true); 336 | setTransFileStatus({ isTranslating: true, transCount: 0 }); 337 | try { 338 | const newnodes = await traslate_file(nodes, lang, on_trans_result); 339 | //download("output.srt", nodesToSrtText(newnodes)); 340 | message.success("翻译字幕文件成功,使用下载功能吧"); 341 | } catch (e) { 342 | message.error("翻译字幕文件失败" + String(e)); 343 | } finally { 344 | setLoading(false); 345 | setTransFileStatus((old) => { 346 | return { ...old, isTranslating: false }; 347 | }); 348 | } 349 | }; 350 | 351 | //所有页数 352 | const get_page_count = () => Math.ceil(nodes.length / PAGE_SIZE); 353 | 354 | const downCall = { 355 | downTrans: function () { 356 | const nodes = transNodes.filter((n) => n); 357 | if (nodes.length == 0) { 358 | message.error("暂无可下载内容"); 359 | return; 360 | } 361 | 362 | download("译文.srt", nodesToSrtText(nodes)); 363 | }, 364 | downTransAndoriginal: function () { 365 | const filterTransNodes = JSON.parse(JSON.stringify(transNodes)).filter( 366 | (n: any) => n 367 | ); 368 | if (filterTransNodes.length == 0) { 369 | message.error("暂无可下载内容"); 370 | return; 371 | } 372 | 373 | const tempTransNodes = filterTransNodes; 374 | 375 | tempTransNodes.forEach((it: any) => { 376 | const currentOriginal = nodes.filter((item) => item?.pos == it?.pos); 377 | if (currentOriginal.length == 1) { 378 | //译 上 源下 379 | it.content = `${it.content}\n${currentOriginal[0].content}`; 380 | } 381 | }); 382 | 383 | download("译文_双语.srt", nodesToSrtText(tempTransNodes)); 384 | }, 385 | }; 386 | //下载点击 387 | const onDownClick: MenuProps["onClick"] = (e) => { 388 | if (e.key == "downTransAndoriginal") { 389 | downCall.downTransAndoriginal(); 390 | return; 391 | } 392 | 393 | downCall.downTrans(); 394 | }; 395 | 396 | //节点编辑回调 397 | function onNodeEditCallBack(newNode: Node) { 398 | //console.log(newNode); 399 | const currentEditNode = transNodes.filter((it) => it.pos == newNode.pos); 400 | if (currentEditNode.length == 0) { 401 | return; 402 | } 403 | 404 | //更新 405 | currentEditNode[0].content = newNode.content; 406 | } 407 | 408 | //分页大小变更 409 | function onPageSizeChange(newValue: number) { 410 | setPageSize(newValue); 411 | } 412 | 413 | return ( 414 | 415 | 416 |
417 | 418 | 419 | 420 | 421 | 分页大小 422 | 423 | 430 | 431 | 432 | 433 | 小技巧:翻译完成后,可直接点击【译文】单元格微调编辑 434 | 435 | 436 | 437 |
438 | 439 |

446 | {curPage + 1} / {Math.ceil(nodes.length / PAGE_SIZE)} 447 |

448 | 449 | 450 | 453 |
22 | 23 | 24 | ); 25 | }; 26 | 27 | interface EditableCellProps { 28 | title: React.ReactNode; 29 | editable: boolean; 30 | children: React.ReactNode; 31 | dataIndex: keyof any; 32 | record: any; 33 | handleSave: (record: any) => void; 34 | } 35 | 36 | //编辑行 37 | export const EditableCell: React.FC = ({ 38 | title, 39 | editable, 40 | children, 41 | dataIndex, 42 | record, 43 | handleSave, 44 | ...restProps 45 | }) => { 46 | const [editing, setEditing] = useState(false); 47 | const inputRef = useRef(null); 48 | const form = useContext(EditableContext)!; 49 | 50 | useEffect(() => { 51 | if (editing) { 52 | inputRef.current!.focus(); 53 | } 54 | }, [editing]); 55 | 56 | const toggleEdit = () => { 57 | setEditing(!editing); 58 | form.setFieldsValue({ [dataIndex]: record[dataIndex] }); 59 | }; 60 | 61 | const save = async () => { 62 | try { 63 | const values = await form.validateFields(); 64 | 65 | toggleEdit(); 66 | handleSave({ ...record, ...values }); 67 | } catch (errInfo) { 68 | console.log("Save failed:", errInfo); 69 | } 70 | }; 71 | 72 | let childNode = children; 73 | 74 | if (editable) { 75 | childNode = editing ? ( 76 | 86 | 87 | 88 | ) : ( 89 |
94 | {children} 95 |
96 | ); 97 | } 98 | 99 | return
; 100 | }; 101 | 102 | type EditableTableProps = Parameters[0]; 103 | 104 | // //数据实体 105 | // interface DataType { 106 | // key: React.Key; 107 | // name: string; 108 | // age: string; 109 | // address: string; 110 | // } 111 | 112 | export type ColumnTypes = Exclude; 113 | -------------------------------------------------------------------------------- /utils/env.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV === "development"; 2 | -------------------------------------------------------------------------------- /utils/fp.ts: -------------------------------------------------------------------------------- 1 | export const sample = (arr: any[] = []) => { 2 | const len = arr === null ? 0 : arr.length; 3 | return len ? arr[Math.floor(Math.random() * len)] : undefined; 4 | }; 5 | 6 | export function getRandomInt(min: number, max: number): number { 7 | return Math.floor(Math.random() * (max - min)) + min; 8 | } 9 | 10 | export async function digestMessage(message: string) { 11 | const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array 12 | const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); // hash the message 13 | const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array 14 | const hashHex = hashArray 15 | .map((b) => b.toString(16).padStart(2, "0")) 16 | .join("").substring(0, 40); // convert bytes to hex string 17 | return hashHex; 18 | } -------------------------------------------------------------------------------- /utils/themeConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeConfig } from "antd"; 2 | 3 | const theme: ThemeConfig = { 4 | token: { 5 | fontSize: 16, 6 | colorPrimary: "#52c41a", 7 | }, 8 | }; 9 | 10 | export default theme; 11 | --------------------------------------------------------------------------------
{childNode}