├── .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 | 
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 | [](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 |
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 | 
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 | [](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 |
18 | 企鹅交流群:812561075
19 |
37 |
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 |
95 |
96 | {/* */}
105 |
114 |
115 |
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 | {/*
104 | Add a row
105 |
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 | label="代理地址" name="customHost">
76 |
77 |
78 | label="ApiKey" name="apiKey">
79 |
80 |
81 | label="延迟" name="delaySecond">
82 |
83 |
84 |
85 | label="模型" name="customModel">
86 | {
88 | return {
89 | value: it,
90 | label: it,
91 | };
92 | })}
93 | />
94 |
95 |
96 | label="Prompt"
97 | name="promptTemplate"
98 | rules={[{ required: true, message: "请输入系统提示语" }]}
99 | >
100 |
101 |
102 |
103 |
104 |
105 | 保存
106 |
107 |
108 |
109 |
110 | 重置
111 |
112 |
113 |
114 |
115 |
116 | );
117 | };
118 |
119 | export default Setting;
120 |
--------------------------------------------------------------------------------
/components/srt/SrtMain.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Image from "next/image";
3 | import { useState } from "react";
4 | import {
5 | getEncoding,
6 | parseSrt,
7 | Node,
8 | nodesToSrtText,
9 | checkIsSrtFile,
10 | nodesToTransNodes,
11 | convertToSrt,
12 | } from "@/lib/srt";
13 | import Subtitles from "@/components/Subtitles";
14 | import { toast, Toaster } from "react-hot-toast";
15 | import styles from "@/styles/Srt.module.css";
16 | import { suportedLangZh, commonLangZh, langBiMap } from "@/lib/lang";
17 | import { CacheKey, ENABLE_SHOP } from "@/utils/constants";
18 | import { getPayload, parse_gpt_resp } from "@/lib/openai/prompt";
19 | import { isDev } from "@/utils/env";
20 | import { OpenAIResult } from "@/lib/openai/OpenAIResult";
21 |
22 | const MAX_FILE_SIZE = 512 * 1024; // 512KB
23 | const PAGE_SIZE = 10;
24 | const MAX_RETRY = 5;
25 |
26 | /**
27 | * 下载文件
28 | * @param filename 文件名
29 | * @param text 文本内容
30 | */
31 | function download(filename: string, text: string) {
32 | var element = document.createElement("a");
33 | element.setAttribute(
34 | "href",
35 | "data:text/plain;charset=utf-8," + encodeURIComponent(text)
36 | );
37 | element.setAttribute("download", filename);
38 |
39 | element.style.display = "none";
40 | document.body.appendChild(element);
41 |
42 | element.click();
43 |
44 | document.body.removeChild(element);
45 | }
46 |
47 | function curPageNodes(nodes: Node[], curPage: number) {
48 | let res = nodes.slice(curPage * PAGE_SIZE, (curPage + 1) * PAGE_SIZE);
49 | if (res.findIndex((n) => n) === -1) {
50 | res = [];
51 | }
52 | return res;
53 | }
54 |
55 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
56 |
57 | /**
58 | * 翻译所有
59 | * @param nodes 待翻译节点列表
60 | * @param lang 目标语言
61 | * @param apiKey apiKey
62 | * @param notifyResult 翻译完 结果通知回调
63 | * @param useGoogle 是否启用谷歌翻译
64 | * @param promptTemplate 提示语模板
65 | * @param customHost 自定义Host
66 | * @param gptModel Gpt模型
67 | * @returns
68 | */
69 | async function traslate_all(
70 | nodes: Node[],
71 | lang: string,
72 | apiKey?: string,
73 | notifyResult?: any,
74 | useGoogle?: boolean,
75 | promptTemplate?: string,
76 | customHost?: string,
77 | gptModel?: string
78 | ) {
79 | const batches: Node[][] = [];
80 | for (let i = 0; i < nodes.length; i += PAGE_SIZE) {
81 | batches.push(nodes.slice(i, i + PAGE_SIZE));
82 | }
83 | // for now, just use sequential execution
84 | const results: Node[] = [];
85 | let batch_num = 0;
86 | for (const batch of batches) {
87 | let success = false;
88 | for (let i = 0; i < MAX_RETRY && !success; i++) {
89 | try {
90 | const r = await translate_one_batch(
91 | batch,
92 | lang,
93 | apiKey,
94 | useGoogle,
95 | promptTemplate,
96 | customHost,
97 | gptModel
98 | );
99 | results.push(...r);
100 | success = true;
101 | if (notifyResult) {
102 | notifyResult(batch_num, r);
103 | }
104 | console.log(`Translated ${results.length} of ${nodes.length}`);
105 | } catch (e) {
106 | console.error(e);
107 | await sleep(3000); // may exceed rate limit, sleep for a while
108 | }
109 | }
110 | batch_num++;
111 | if (!success) {
112 | console.error(`translate_all failed for ${batch}`);
113 | throw new Error(`translate file ${batch} failed`);
114 | }
115 | }
116 | return results;
117 | }
118 |
119 | /**
120 | * 按照节点列表翻译
121 | * @param nodes 待翻译节点列表
122 | * @param lang 目标语言
123 | * @param apiKey apiKey
124 | * @param useGoogle 是否启用谷歌翻译
125 | * @param promptTemplate 提示语模板
126 | * @param customHost 自定义Host
127 | * @param gptModel gpt模型
128 | * @returns
129 | */
130 | async function translate_one_batch(
131 | nodes: Node[],
132 | lang: string,
133 | apiKey?: string,
134 | useGoogle?: boolean,
135 | promptTemplate?: string,
136 | customHost?: string,
137 | gptModel?: string
138 | ) {
139 | const sentences = nodes.map((node) => node.content);
140 | // if last sentence ends with ",", remove it
141 | const lastSentence = sentences[sentences.length - 1];
142 | if (lastSentence.endsWith(",") || lastSentence.endsWith(",")) {
143 | sentences[sentences.length - 1] = lastSentence.substring(
144 | 0,
145 | lastSentence.length - 1
146 | );
147 | }
148 |
149 | // let options = {
150 | // method: "POST",
151 | // headers: {
152 | // "Content-Type": "application/json",
153 | // },
154 | // body: JSON.stringify({
155 | // targetLang: lang,
156 | // sentences: sentences,
157 | // apiKey: apiKey,
158 | // promptTemplate: promptTemplate,
159 | // baseHost: customHost,
160 | // gptModel: gptModel,
161 | // }),
162 | // };
163 |
164 | // console.time("request /api/translate");
165 | // const url = useGoogle ? "/api/googleTran" : "/api/translate";
166 | // const res = await fetch(url, options);
167 |
168 | //获取请求体
169 | const payload = getPayload(
170 | sentences,
171 | lang,
172 | undefined,
173 | promptTemplate,
174 | gptModel
175 | );
176 |
177 | const { res_keys } = payload;
178 |
179 | try {
180 | isDev && apiKey && console.log("=====use user api key=====");
181 | isDev && customHost && console.log("=====use user custom host=====");
182 | isDev && console.log("payload", payload);
183 | const result = await OpenAIResult(payload, apiKey, customHost);
184 | const resp = parse_gpt_resp(result, res_keys!);
185 |
186 | // let rkey = `${targetLang}_${srcLang}_${sentences}}`;
187 | // rkey = "tranres_" + await digestMessage(rkey);
188 | // const data = await redis.set(rkey, JSON.stringify(resp));
189 | // console.log("cached data", data);
190 |
191 | //return NextResponse.json(resp);
192 | //todo:
193 | console.log("resp", resp);
194 | // console.timeEnd("request /api/translate");
195 |
196 | // if (res.redirected) {
197 | // throw new Error(" rate limited. Please enter you OpenAI key");
198 | // }
199 |
200 | //const jres = await resp.json();
201 | // if (jres.errorMessage) {
202 | // throw new Error(jres.errorMessage);
203 | // }
204 | return nodesToTransNodes(nodes, resp);
205 | } catch (error: any) {
206 | console.log("API error", error, error.message);
207 | // return NextResponse.json({
208 | // errorMessage: error.message,
209 | // });
210 | throw new Error("翻译异常" + error.message);
211 | }
212 | }
213 |
214 | /**
215 | * 清空文件
216 | */
217 | function clearFileInput() {
218 | const finput = document.getElementById("file") as HTMLInputElement;
219 | if (finput) {
220 | finput.value = "";
221 | }
222 | }
223 |
224 | /**
225 | * 转换文件时的状态
226 | */
227 | type TranslateFileStatus = {
228 | isTranslating: boolean;
229 | transCount: number;
230 | };
231 |
232 | export default function Srt() {
233 | const [nodes, setNodes] = useState([]);
234 | const [transNodes, setTransNodes] = useState([]); // make transNode the same structure as nodes
235 | const [curPage, setCurPage] = useState(0);
236 | const [filename, setFilename] = useState("");
237 | const [loading, setLoading] = useState(false);
238 | const [transFileStatus, setTransFileStatus] = useState({
239 | isTranslating: false,
240 | transCount: 0,
241 | });
242 | const [showAllLang, setShowAllLang] = useState(false);
243 | const langs = showAllLang ? suportedLangZh : commonLangZh;
244 | const isEnglish = false;
245 | const modleOptions = [
246 | "gpt-3.5-turbo-0613",
247 | "gpt-3.5-turbo-16k-0613",
248 | "gpt-3.5-turbo",
249 | "gpt-3.5-turbo-0301",
250 | "gpt-3.5-turbo-16k",
251 | ];
252 |
253 | const getUseGoogle = () => {
254 | const res = localStorage.getItem("translate-engine");
255 | if (res) {
256 | return JSON.parse(res) === "google";
257 | }
258 | return false;
259 | };
260 |
261 | const getUserKey = () => {
262 | const res = localStorage.getItem(CacheKey.UserApikeyWithOpenAi);
263 | if (res) return JSON.parse(res) as string;
264 | };
265 |
266 | const getUserCustomHost = () => {
267 | const res = localStorage.getItem(CacheKey.UserBaseHostWithOpenAi);
268 | if (res) return JSON.parse(res) as string;
269 | };
270 |
271 | //提示语
272 | const getUserPrompt = () => {
273 | return (document.getElementById("txt_promptTemplate") as HTMLSelectElement)
274 | .value;
275 | // const res = localStorage.getItem("user-prompt-template");
276 | // if (res) return res;
277 | };
278 |
279 | //多语言
280 | const getLang = () => {
281 | return (document.getElementById("langSelect") as HTMLSelectElement).value;
282 | };
283 |
284 | //模型
285 | const getModel = () => {
286 | return (document.getElementById("modelSelect") as HTMLSelectElement).value;
287 | };
288 |
289 | /**
290 | * 添加字幕并渲染
291 | * @param text 文本内容
292 | * @param fname 文件名
293 | * @returns
294 | */
295 | const onNewSubtitleText = (text: string, fname: string) => {
296 | if (!checkIsSrtFile(text)) {
297 | const converted = convertToSrt(text);
298 | if (converted) {
299 | text = converted;
300 | } else {
301 | toast.error("Cannot convert to a valid SRT file");
302 | clearFileInput();
303 | return;
304 | }
305 | }
306 | const nodes = parseSrt(text);
307 | setNodes(nodes);
308 | setTransNodes([]);
309 | setCurPage(0);
310 | setFilename(fname);
311 | };
312 |
313 | //默认列表
314 | // useEffect(() => {
315 | // (async () => {
316 | // const resp = await fetch("/1900s.srt");
317 | // const text = await resp.text();
318 | // onNewSubtitleText(text, "1900 (Movie) example");
319 | // })();
320 | // }, []);
321 |
322 | /**
323 | * 选择字幕文件
324 | * @returns
325 | */
326 | const onChooseFile = async (e: any) => {
327 | const input = e.target;
328 | const f: File = input.files[0];
329 | if (!f) return;
330 | if (f.size > MAX_FILE_SIZE) {
331 | toast.error("最大只支持大小512kb");
332 | clearFileInput();
333 | return;
334 | }
335 | const encoding = await getEncoding(f);
336 | if (!encoding) {
337 | toast.error("无法以文本打开");
338 | clearFileInput();
339 | return;
340 | }
341 | const data = await f.arrayBuffer();
342 | let text = new TextDecoder(encoding!).decode(data);
343 | onNewSubtitleText(text, f.name);
344 | };
345 |
346 | const toPage = (delta: number) => {
347 | const newPage = curPage + delta;
348 | if (newPage < 0 || newPage >= nodes.length / PAGE_SIZE) return;
349 | setCurPage(newPage);
350 | };
351 |
352 | const on_trans_result = (batch_num: number, tnodes: Node[]) => {
353 | setTransFileStatus((old) => {
354 | return { ...old, transCount: batch_num + 1 };
355 | });
356 | setTransNodes((nodes) => {
357 | const nodesCopy = [...nodes];
358 | for (let i = 0; i < PAGE_SIZE; i++) {
359 | nodesCopy[batch_num * PAGE_SIZE + i] = tnodes[i];
360 | }
361 | return nodesCopy;
362 | });
363 | };
364 |
365 | /**
366 | * 翻译整个文件
367 | */
368 | const translateFile = async () => {
369 | setTransFileStatus({ isTranslating: true, transCount: 0 });
370 | try {
371 | const newnodes = await traslate_all(
372 | nodes,
373 | getLang(),
374 | getUserKey(),
375 | on_trans_result,
376 | getUseGoogle(),
377 | getUserPrompt(),
378 | getUserCustomHost(),
379 | getModel()
380 | );
381 | //download("output.srt", nodesToSrtText(newnodes));
382 | toast.success("翻译字幕文件成功");
383 | } catch (e) {
384 | toast.error("翻译字幕文件失败" + String(e));
385 | }
386 | setTransFileStatus((old) => {
387 | return { ...old, isTranslating: false };
388 | });
389 | };
390 |
391 | /**
392 | * 翻译当前页面
393 | */
394 | const translate = async () => {
395 | setLoading(true);
396 | try {
397 | const newnodes = await translate_one_batch(
398 | curPageNodes(nodes, curPage),
399 | getLang(),
400 | getUserKey(),
401 | getUseGoogle(),
402 | getUserPrompt(),
403 | getUserCustomHost(),
404 | getModel()
405 | );
406 | setTransNodes((nodes) => {
407 | const nodesCopy = [...nodes];
408 | for (let i = 0; i < PAGE_SIZE; i++) {
409 | nodesCopy[curPage * PAGE_SIZE + i] = newnodes[i];
410 | }
411 | return nodesCopy;
412 | });
413 | } catch (e) {
414 | console.error("translate failed", e);
415 | toast.error("翻译失败" + String(e));
416 | }
417 | setLoading(false);
418 | };
419 |
420 | /**
421 | * 下载源文件
422 | */
423 | const download_original = () => {
424 | if (nodes.length == 0) {
425 | toast.error("暂无可下载内容");
426 | return;
427 | }
428 |
429 | download("original.srt", nodesToSrtText(nodes));
430 | };
431 |
432 | /**
433 | * 下载翻译字幕
434 | */
435 | const download_translated = () => {
436 | const nodes = transNodes.filter((n) => n);
437 | if (nodes.length == 0) {
438 | toast.error("暂无可下载内容");
439 | return;
440 | }
441 |
442 | download("translated.srt", nodesToSrtText(nodes));
443 | };
444 |
445 | /**
446 | * 下载双语字幕
447 | */
448 | const download_translated_retain_original = () => {
449 | const filterTransNodes = JSON.parse(JSON.stringify(transNodes)).filter(
450 | (n: any) => n
451 | );
452 | if (filterTransNodes.length == 0) {
453 | toast.error("暂无可下载内容");
454 | return;
455 | }
456 |
457 | const tempTransNodes = filterTransNodes;
458 |
459 | tempTransNodes.forEach((it: any) => {
460 | const currentOriginal = nodes.filter((item) => item?.pos == it?.pos);
461 | if (currentOriginal.length == 1) {
462 | //译 上 源下
463 | it.content = `${it.content}\n${currentOriginal[0].content}`;
464 | }
465 | });
466 |
467 | download("translated_双语.srt", nodesToSrtText(tempTransNodes));
468 | };
469 |
470 | const get_page_count = () => Math.ceil(nodes.length / PAGE_SIZE);
471 |
472 | return (
473 | <>
474 |
475 | {"AI字幕助手 Powered by OpenAI "}
476 |
477 |
478 |
483 |
484 | {"支持翻译本地SRT/ASS格式字幕\nPowered by OpenAI GPT-3.5"}
485 |
486 |
494 | ***
495 | 注:尽量使用自建代理或者社区公开代理,如需直连请自行本地准备好【环境】
496 |
497 |
498 |
507 |
514 |
515 |
516 | 模板信息
517 |
518 |
527 |
* 非必要可不用改
528 |
529 |
530 |
535 | {"选择字幕文件"}
536 |
542 |
543 |
544 |
545 | 选择模型
546 |
547 |
548 | {modleOptions.map((item) => (
549 |
550 | {item}
551 |
552 | ))}
553 |
554 |
555 |
556 |
toPage(-1)}
559 | type="button"
560 | >
561 | {"上一页"}
562 |
563 |
570 | {curPage + 1} / {Math.ceil(nodes.length / PAGE_SIZE)}
571 |
572 |
toPage(1)}
575 | type="button"
576 | >
577 | {"下一页"}
578 |
579 |
580 |
581 | {"目标语言"}
582 |
583 |
584 | {langs.map((lang) => (
585 |
586 | {isEnglish ? langBiMap.get(lang) : lang}
587 |
588 | ))}
589 |
590 |
setShowAllLang(e.target.checked)}
596 | >
597 | {!loading ? (
598 |
605 | {"翻译本页"}
606 |
607 | ) : (
608 |
614 |
621 |
627 |
628 |
629 | )}
630 |
631 |
632 | {filename ? filename : "未选择字幕"}
633 |
634 |
638 |
647 | {!transFileStatus.isTranslating ? (
648 |
657 | {"翻译整个文件"}
658 |
659 | ) : (
660 |
670 |
676 | {"进度"}
677 | {transFileStatus.transCount}/{get_page_count()}
678 |
679 | )}
680 |
685 | {"下载原文字幕"}
686 |
687 |
692 | {"下载译文字幕"}
693 |
694 |
695 |
700 | 下载双语字幕
701 |
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 |
toPage(-1)}>上一页
439 |
446 | {curPage + 1} / {Math.ceil(nodes.length / PAGE_SIZE)}
447 |
448 |
toPage(1)}>下一页
449 |
450 |
451 | 目标语言
452 |
453 |
{
461 | return {
462 | value: it,
463 | label: it,
464 | };
465 | })}
466 | />
467 |
468 | setShowAllLang(checked)}
473 | />
474 |
475 |
476 |
481 | {loading ? "翻译中,请稍等" : "翻页本页"}
482 |
483 |
484 |
485 |
486 |
493 |
494 | {transFileStatus.isTranslating
495 | ? `进度:${transFileStatus.transCount}/${get_page_count()}`
496 | : "翻译整个文件"}
497 |
498 |
499 |
500 |
501 |
502 |
506 | 译文下载
507 |
508 |
509 |
510 |
511 |
512 |
517 |
518 |
519 | );
520 | };
521 |
522 | export default SrtNew;
523 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | };
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/lib/__tests__/bilibili.test.ts:
--------------------------------------------------------------------------------
1 | import { fetchSubtitle } from "../bilibili";
2 | import * as srt from "../srt";
3 | import {suportedLang, suportedLangZh, commonLangZh} from "../lang";
4 | import { fetchYoutubeSubtitle, fetchYoutubeSubtitleToNodes } from "../youtube";
5 | import { parseSync, stringifySync, formatTimestamp } from "subtitle";
6 | import { readFileSync } from "fs";
7 |
8 | import { parse_gpt_resp } from "../openai/prompt";
9 |
10 | test("test", async () => {
11 | expect(1+2).toBe(3);
12 |
13 | // const text = await srt.readTextFile("/Users/cgsv/work/vtts_demo/hehe.srt");
14 | // const nodes = srt.parseSrt(text);
15 | // const text2 = srt.nodesToSrtText(nodes) + "\r\n\r\n";
16 |
17 | // expect(text).toBe(text2);
18 | console.log(commonLangZh);
19 | console.log(suportedLangZh.length, suportedLang.length);
20 | for (const lang of commonLangZh) {
21 | console.log(lang, suportedLang[suportedLangZh.indexOf(lang)]);
22 | }
23 |
24 | let text = readFileSync("/Users/cgsv/work/vicweb/nextjs/public/1900s.srt", "utf8");
25 | const res = parseSync(text);
26 |
27 | let r0 = res[0]
28 |
29 | console.log(r0, formatTimestamp(76867));
30 |
31 | const s = `8774
32 | 当你不知道它是什么时,
33 | 那就是爵士乐!
34 |
35 | 3422
36 | 爵士乐!
37 |
38 | 3301
39 | 你叫什么名字?
40 | - 马克斯·图尼,先生。
41 |
42 | 5780
43 | 没错!
44 |
45 | 9751
46 | 那是我一生中最幸福的一天。
47 |
48 | 3336
49 | 所有那些人,
50 | 眼中充满希望,
51 |
52 | 1229
53 | 告别、警笛和那个
54 | 巨大的漂浮世界开始移动。
55 |
56 | 4117
57 | 感觉就像一个大型派对,
58 | 一个巨大的集市,只为我而设。
59 |
60 | 6922
61 | 但仅仅三天后,海洋
62 | 厌倦了这些庆祝活动。
63 |
64 | 8923
65 | 突然,在深夜里,
66 | 他疯狂了,一切都失控了。`
67 | console.log(parse_gpt_resp(s, [8774,3422,3301]))
68 |
69 | // const res = await fetchYoutubeSubtitleToNodes("MALGrKvXql4");
70 | // console.log(res);
71 | }, 120 * 1000);
--------------------------------------------------------------------------------
/lib/ass_to_srt.ts:
--------------------------------------------------------------------------------
1 | const re_ass = new RegExp('Dialogue:\\s\\d,' + // get time and subtitle
2 | '(\\d+:\\d\\d:\\d\\d.\\d\\d),' + // start time
3 | '(\\d+:\\d\\d:\\d\\d.\\d\\d),' + // end time
4 | '([^,]*),' + // object
5 | '([^,]*),' + // actor
6 | '(?:[^,]*,){4}' +
7 | '(.*)$', 'i'); // subtitle
8 | const re_newline = /\\n/ig // replace \N with newline
9 | const re_style = /\{[^}]+\}/g // replace style
10 |
11 | export function ass_to_srt(assText: string) {
12 | var srts: any[] = [];
13 | String(assText).split(/\r*\n/).forEach(function(line) {
14 | var m = line.match(re_ass);
15 | if(!m) {console.log(line); return;}
16 |
17 | var start = m[1], end = m[2], what = m[3], actor = m[4], text = m[5];
18 | text = text.replace(re_style, '').replace(re_newline, '\r\n');
19 | srts.push({start: start, end: end, text: text});
20 | });
21 |
22 | var i = 1;
23 | var output = srts.sort(function(d1, d2) {
24 | var s1 = assTime2Int(d1.start);
25 | var s2 = assTime2Int(d2.start);
26 | var e1 = assTime2Int(d1.end);
27 | var e2 = assTime2Int(d2.end);
28 |
29 | return s1 != s2 ? s1 - s2 : e1 - e2;
30 | }).map(function(srt) {
31 | var start = assTime2SrtTime(srt.start);
32 | var end = assTime2SrtTime(srt.end);
33 | return (i++) + '\n'
34 | + start + ' --> ' + end + '\n'
35 | + srt.text + '\n\n';
36 | }).join('');
37 |
38 | return output;
39 | };
40 |
41 | function assTime2Int(assTime: string) {
42 | return parseInt(assTime.replace(/[^0-9]/g, ''));
43 | }
44 |
45 | function assTime2SrtTime(assTime: string) {
46 | var h = '00', ms = '000';
47 | var m = h; var s = h;
48 | var t = assTime.split(':');
49 | if(t.length > 0) h = t[0].length == 1 ? '0'+t[0] : t[0];
50 | if(t.length > 1) m = t[1].length == 1 ? '0'+t[1] : t[1];
51 | if(t.length > 2) {
52 | var t2 = t[2].split('.');
53 | if(t2.length > 0) s = t2[0].length == 1 ? '0'+t2[0] : t2[0];
54 | if(t2.length > 0) ms = t2[1].length == 2 ? '0'+t2[1] : t2[1].length == 1 ? '00'+ t2[1] : t2[1];
55 | }
56 | return [h, m , s+','+ms].join(':');
57 | }
--------------------------------------------------------------------------------
/lib/lang.ts:
--------------------------------------------------------------------------------
1 | const suportedLang = `
2 | Albanian
3 | Arabic
4 | Armenian
5 | Awadhi
6 | Azerbaijani
7 | Bashkir
8 | Basque
9 | Belarusian
10 | Bengali
11 | Bhojpuri
12 | Bosnian
13 | Brazilian Portuguese
14 | Bulgarian
15 | Cantonese (Yue)
16 | Catalan
17 | Chhattisgarhi
18 | Chinese
19 | Croatian
20 | Czech
21 | Danish
22 | Dogri
23 | Dutch
24 | English
25 | Estonian
26 | Faroese
27 | Finnish
28 | French
29 | Galician
30 | Georgian
31 | German
32 | Greek
33 | Gujarati
34 | Haryanvi
35 | Hindi
36 | Hungarian
37 | Indonesian
38 | Irish
39 | Italian
40 | Japanese
41 | Javanese
42 | Kannada
43 | Kashmiri
44 | Kazakh
45 | Konkani
46 | Korean
47 | Kyrgyz
48 | Latvian
49 | Lithuanian
50 | Macedonian
51 | Maithili
52 | Malay
53 | Maltese
54 | Mandarin
55 | Mandarin Chinese
56 | Marathi
57 | Marwari
58 | Min Nan
59 | Moldovan
60 | Mongolian
61 | Montenegrin
62 | Nepali
63 | Norwegian
64 | Oriya
65 | Pashto
66 | Persian (Farsi)
67 | Polish
68 | Portuguese
69 | Punjabi
70 | Rajasthani
71 | Romanian
72 | Russian
73 | Sanskrit
74 | Santali
75 | Serbian
76 | Sindhi
77 | Sinhala
78 | Slovak
79 | Slovene
80 | Slovenian
81 | Spanish
82 | Swahili
83 | Swedish
84 | Tajik
85 | Tamil
86 | Tatar
87 | Telugu
88 | Thai
89 | Turkish
90 | Turkmen
91 | Ukrainian
92 | Urdu
93 | Uzbek
94 | Vietnamese
95 | Welsh
96 | Wu
97 | `.trim().split("\n");
98 |
99 |
100 |
101 | const suportedLangZh = `
102 | 阿尔巴尼亚语
103 | 阿拉伯语
104 | 亚美尼亚语
105 | 阿瓦德语
106 | 阿塞拜疆语
107 | 巴什基尔语
108 | 巴斯克语
109 | 白俄罗斯语
110 | 孟加拉语
111 | 博杰普尔语
112 | 波斯尼亚语
113 | 巴西葡萄牙语
114 | 保加利亚语
115 | 粤语(粤语)
116 | 加泰罗尼亚语
117 | 查蒂斯加里语
118 | 中文
119 | 克罗地亚语
120 | 捷克语
121 | 丹麦语
122 | 多格里语
123 | 荷兰语
124 | 英语
125 | 爱沙尼亚语
126 | 法罗语
127 | 芬兰语
128 | 法语
129 | 加利西亚语
130 | 格鲁吉亚语
131 | 德语
132 | 希腊语
133 | 古吉拉特语
134 | 哈里亚纳语
135 | 印地语
136 | 匈牙利语
137 | 印度尼西亚语
138 | 爱尔兰语
139 | 意大利语
140 | 日语
141 | 爪哇语
142 | 卡纳达语
143 | 克什米尔语
144 | 哈萨克语
145 | 孔卡尼语
146 | 韩语
147 | 吉尔吉斯语
148 | 拉脱维亚语
149 | 立陶宛语
150 | 马其顿语
151 | 迈蒂利语
152 | 马来语
153 | 马耳他语
154 | 普通话
155 | 普通话(普通话)
156 | 马拉地语
157 | 马尔瓦里语
158 | 闽南语
159 | 摩尔多瓦语
160 | 蒙古语
161 | 黑山语
162 | 尼泊尔语
163 | 挪威语
164 | 奥里亚语
165 | 普什图语
166 | 波斯语(法斯语)
167 | 波兰语
168 | 葡萄牙语
169 | 旁遮普语
170 | 拉贾斯坦语
171 | 罗马尼亚语
172 | 俄语
173 | 梵语
174 | 桑塔利语
175 | 塞尔维亚语
176 | 信德语
177 | 僧伽罗语
178 | 斯洛伐克语
179 | 斯洛文尼亚语
180 | 斯洛文尼亚语()
181 | 西班牙语
182 | 斯瓦希里语
183 | 瑞典语
184 | 塔吉克语
185 | 泰米尔语
186 | 鞑靼语
187 | 特拉古语
188 | 泰语
189 | 土耳其语
190 | 土库曼语
191 | 乌克兰语
192 | 乌尔都语
193 | 乌兹别克语
194 | 越南语
195 | 威尔士语
196 | 吴语
197 | `.trim().split("\n");
198 |
199 | const locales = `
200 | Albanian - sq
201 | Arabic - ar
202 | Armenian - hy
203 | Awadhi - awa
204 | Azerbaijani - az
205 | Bashkir - ba
206 | Basque - eu
207 | Belarusian - be
208 | Bengali - bn
209 | Bhojpuri - bho
210 | Bosnian - bs
211 | Brazilian Portuguese - pt-BR
212 | Bulgarian - bg
213 | Cantonese (Yue) - yue
214 | Catalan - ca
215 | Chhattisgarhi - hne
216 | Chinese - zh-CN
217 | Croatian - hr
218 | Czech - cs
219 | Danish - da
220 | Dogri - doi
221 | Dutch - nl
222 | English - en
223 | Estonian - et
224 | Faroese - fo
225 | Finnish - fi
226 | French - fr
227 | Galician - gl
228 | Georgian - ka
229 | German - de
230 | Greek - el
231 | Gujarati - gu
232 | Haryanvi - bgc
233 | Hindi - hi
234 | Hungarian - hu
235 | Indonesian - id
236 | Irish - ga
237 | Italian - it
238 | Japanese - ja
239 | Javanese - jv
240 | Kannada - kn
241 | Kashmiri - ks
242 | Kazakh - kk
243 | Konkani - kok
244 | Korean - ko
245 | Kyrgyz - ky
246 | Latvian - lv
247 | Lithuanian - lt
248 | Macedonian - mk
249 | Maithili - mai
250 | Malay - ms
251 | Maltese - mt
252 | Mandarin - zh-CN
253 | Mandarin Chinese - zh-CN
254 | Marathi - mr
255 | Marwari - mwr
256 | Min Nan - nan
257 | Moldovan - mo
258 | Mongolian - mn
259 | Montenegrin - cnr
260 | Nepali - ne
261 | Norwegian - no
262 | Oriya - or
263 | Pashto - ps
264 | Persian (Farsi) - fa
265 | Polish - pl
266 | Portuguese - pt
267 | Punjabi - pa
268 | Rajasthani - raj
269 | Romanian - ro
270 | Russian - ru
271 | Sanskrit - sa
272 | Santali - sat
273 | Serbian - sr
274 | Sindhi - sd
275 | Sinhala - si
276 | Slovak - sk
277 | Slovene - sl
278 | Slovenian - sl
279 | Spanish - es
280 | Swahili - sw
281 | Swedish - sv
282 | Tajik - tg
283 | Tamil - ta
284 | Tatar - tt
285 | Telugu - te
286 | Thai - th
287 | Turkish - tr
288 | Turkmen - tk
289 | Ukrainian - uk
290 | Urdu - ur
291 | Uzbek - uz
292 | Vietnamese - vi
293 | Welsh - cy
294 | Wu - wuu
295 | `.trim().split("\n").map(line => line.split(" - "));
296 |
297 | const commonLangZh = `
298 | 中文
299 | 英语
300 | 西班牙语
301 | 阿拉伯语
302 | 印地语
303 | 葡萄牙语
304 | 俄语
305 | 日语
306 | 法语
307 | 德语
308 | 韩语
309 | 意大利语
310 | 土耳其语
311 | 孟加拉语
312 | `.trim().split("\n");
313 |
314 | const langBiMap = (() => {
315 | const res = new Map();
316 | for (let i = 0; i < suportedLang.length; i++) {
317 | res.set(suportedLang[i], suportedLangZh[i]);
318 | res.set(suportedLangZh[i], suportedLang[i]);
319 | }
320 | return res;
321 | })();
322 |
323 | const langLocaleBiMap = (() => {
324 | const res = new Map();
325 | for (const words of locales) {
326 | res.set(words[0], words[1]);
327 | res.set(words[1], words[0]);
328 | }
329 | return res;
330 | })();
331 |
332 | function getLocale(lang: string): string | undefined {
333 | let res = langLocaleBiMap.get(lang);
334 | if (!res) {
335 | const lang1 = langBiMap.get(lang);
336 | if (lang1) {
337 | res = langLocaleBiMap.get(lang1);
338 | }
339 | }
340 | return res;
341 | }
342 |
343 | export {suportedLang, suportedLangZh, commonLangZh, getLocale, langBiMap, langLocaleBiMap};
--------------------------------------------------------------------------------
/lib/lemon.ts:
--------------------------------------------------------------------------------
1 | export async function activateLicenseKey(licenseKey: string, uid?: string) {
2 | // https://docs.lemonsqueezy.com/help/licensing/license-api
3 | const response = await fetch(
4 | `https://api.lemonsqueezy.com/v1/licenses/activate`,
5 | {
6 | method: "POST",
7 | headers: {
8 | "Content-Type": "application/json",
9 | Authorization: `Bearer ${process.env.LEMON_API_KEY ?? ""}`,
10 | },
11 | body: JSON.stringify({
12 | license_key: licenseKey,
13 | instance_name: uid || "ai.cgsv.top",
14 | }),
15 | }
16 | );
17 | const result = await response.json();
18 | return result.activated;
19 | }
--------------------------------------------------------------------------------
/lib/openai/OpenAIResult.ts:
--------------------------------------------------------------------------------
1 | // TODO: maybe chat with video?
2 | export type ChatGPTAgent = "user" | "system" | "assistant";
3 |
4 | export interface ChatGPTMessage {
5 | role: ChatGPTAgent;
6 | content: string;
7 | }
8 | export interface OpenAIStreamPayload {
9 | api_key?: string;
10 | model: string;
11 | messages: ChatGPTMessage[];
12 | temperature: number;
13 | top_p: number;
14 | frequency_penalty: number;
15 | presence_penalty: number;
16 | max_tokens: number;
17 | stream: boolean;
18 | n: number;
19 | res_keys?: number[];
20 | }
21 | import { checkOpenaiApiKeys } from "./openai";
22 | import { sample } from "../../utils/fp";
23 | import { DEFAULT_BASE_URL_HOST } from "@/utils/constants";
24 |
25 | function formatResult(result: any) {
26 | const answer = result.choices[0].message?.content || "";
27 | if (answer.startsWith("\n\n")) {
28 | return answer.substring(2);
29 | }
30 | return answer;
31 | }
32 |
33 | function selectApiKey(apiKey: string | undefined) {
34 | if (apiKey && checkOpenaiApiKeys(apiKey)) {
35 | const userApiKeys = apiKey.split(",");
36 | return sample(userApiKeys);
37 | }
38 |
39 | // don't need to validate anymore, already verified in middleware?
40 | const myApiKeyList = process.env.OPENAI_API_KEY;
41 | const luckyApiKey = sample(myApiKeyList?.split(","));
42 | return luckyApiKey || "";
43 | }
44 |
45 | /**
46 | * 请求OpenAI
47 | * @param payload 请求内容
48 | * @param apiKey ApiKey
49 | * @param customBaseHost 自定义Host
50 | * @returns
51 | */
52 | export async function OpenAIResult(
53 | payload: OpenAIStreamPayload,
54 | apiKey?: string,
55 | customBaseHost?: string
56 | ) {
57 | const openai_api_key = selectApiKey(apiKey);
58 | payload.res_keys = undefined;
59 | const baseHost = customBaseHost ?? DEFAULT_BASE_URL_HOST;
60 | const res = await fetch(baseHost + "/v1/chat/completions", {
61 | headers: {
62 | "Content-Type": "application/json",
63 | Authorization: `Bearer ${openai_api_key ?? ""}`,
64 | },
65 | method: "POST",
66 | body: JSON.stringify(payload),
67 | });
68 |
69 | if (!res.ok) {
70 | const errorResult = await res.json();
71 | throw new Error(
72 | JSON.stringify({
73 | code: errorResult.error.code ?? errorResult.error.type,
74 | msg: errorResult.error.message,
75 | })
76 | );
77 | }
78 |
79 | if (!payload.stream) {
80 | const result = await res.json();
81 | return formatResult(result);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/lib/openai/openai.ts:
--------------------------------------------------------------------------------
1 | export function checkOpenaiApiKey(str: string) {
2 | // var pattern = /^sk-[A-Za-z0-9]{48}$/;
3 | // return pattern.test(str);
4 | return str.length > 0
5 | }
6 |
7 | export function checkOpenaiApiKeys(str: string) {
8 | if (str.includes(",")) {
9 | const userApiKeys = str.split(",");
10 | return userApiKeys.every(checkOpenaiApiKey);
11 | }
12 |
13 | return checkOpenaiApiKey(str);
14 | }
15 |
--------------------------------------------------------------------------------
/lib/openai/prompt.ts:
--------------------------------------------------------------------------------
1 | import { getRandomInt } from "../../utils/fp";
2 | import { Node } from "../srt";
3 | import { OpenAIStreamPayload } from "./OpenAIResult";
4 | import { DEFAULT_PROMPT } from "../../utils/constants";
5 |
6 | // gpt返回格式为: '1\n我仍然在问自己,当我离开这个漂浮的城市时,我是否做了正确的事情。\n\n2\n我不仅指工作。'
7 | export function parse_gpt_resp(content: string, res_keys: number[]) {
8 | console.log(content);
9 | const lines = content
10 | .split("\n")
11 | .map((line) => line.trim())
12 | .filter((line) => line.length > 0);
13 | const positions = res_keys.map((i) => lines.indexOf(String(i)));
14 | const res: string[] = [];
15 | for (let i = 0; i < positions.length; i++) {
16 | const p1 = positions[i];
17 | if (p1 === -1) {
18 | res.push("");
19 | } else {
20 | let next_pos = positions.slice(i + 1).find((p2) => p2 !== -1);
21 | res.push(lines.slice(p1 + 1, next_pos).join("\n"));
22 | }
23 | }
24 | console.log(res);
25 | return res;
26 | }
27 |
28 | // 1\nhello\n\n
29 | export function nodesToQueryText(nodes: Node[]) {
30 | return nodes.map((n) => n.pos + "\n" + n.content + "\n").join("\n");
31 | }
32 |
33 | /**
34 | * 生成gpt system role消息
35 | * @param targetLang 目标语言
36 | * @param srcLang 源语言
37 | * @param promptTemplate 提示语模板内容
38 | * @returns
39 | */
40 | function systemMessage(
41 | targetLang: string,
42 | srcLang?: string,
43 | promptTemplate?: string
44 | ) {
45 | let prompt = (promptTemplate || DEFAULT_PROMPT).replace(
46 | "{{target_lang}}",
47 | targetLang
48 | );
49 | if (srcLang) {
50 | prompt = prompt.replace("{{src_lang}}", srcLang);
51 | }
52 | return prompt;
53 | }
54 |
55 | const rand4 = () => getRandomInt(1000, 9999);
56 |
57 | function rand4_n(n: number) {
58 | const res: number[] = [];
59 | for (let i = 0; i < n; i++) {
60 | let r = rand4();
61 | while (res.indexOf(r) != -1) r = rand4();
62 | res.push(rand4());
63 | }
64 | return res;
65 | }
66 |
67 | function sentences_to_nodes(sentences: string[], rands: number[]): Node[] {
68 | return sentences.map((sentence, idx) => {
69 | return { pos: rands[idx].toString(), content: sentence };
70 | });
71 | }
72 |
73 | /**
74 | * 生成gpt 请求信息
75 | * @param sentences 待翻译字幕列表
76 | * @param targetLang 目标语言
77 | * @param srcLang 原语言
78 | * @param promptTemplate 提示语模板内容
79 | * @param gptModel gpt模型
80 | * @returns
81 | */
82 | export function getPayload(
83 | sentences: string[],
84 | targetLang: string,
85 | srcLang?: string,
86 | promptTemplate?: string,
87 | gptModel?: string
88 | ) {
89 | const rands = rand4_n(sentences.length);
90 | const nodes = sentences_to_nodes(sentences, rands);
91 |
92 | //暂时根据模型来 默认4k gpt4或者16k为 8k
93 | let defaultMaxTokens = 4000;
94 | if (
95 | gptModel &&
96 | (gptModel.indexOf("16k") != -1 || gptModel.indexOf("gpt4") != -1)
97 | ) {
98 | defaultMaxTokens = 8000;
99 | }
100 |
101 | const payload: OpenAIStreamPayload = {
102 | model: gptModel ?? "gpt-3.5-turbo",
103 | messages: [
104 | {
105 | role: "system" as const,
106 | content: systemMessage(targetLang, srcLang, promptTemplate),
107 | },
108 | { role: "user" as const, content: nodesToQueryText(nodes) },
109 | ],
110 | temperature: 0, // translate task temperature 0?
111 | top_p: 1,
112 | frequency_penalty: 0,
113 | presence_penalty: 0,
114 | max_tokens: defaultMaxTokens,
115 | stream: false,
116 | n: 1,
117 | res_keys: rands,
118 | };
119 | return payload;
120 | }
121 |
--------------------------------------------------------------------------------
/lib/srt.ts:
--------------------------------------------------------------------------------
1 | import languageEncoding from "detect-file-encoding-and-language";
2 | import { ass_to_srt } from "./ass_to_srt";
3 | import { parseSync, formatTimestamp } from "subtitle";
4 |
5 | export type Node = {
6 | pos: string;
7 | timestamp?: string;
8 | content: string;
9 | }
10 |
11 | export function convertToSrt(input: string): string | undefined {
12 | return ass_to_srt(input);
13 | }
14 |
15 | export function checkIsSrtFile(content: string) {
16 | const p1 = content.indexOf("\n");
17 | const r = /^\s*(\d+:\d+:\d+,\d+)[^\S\n]+-->[^\S\n]+(\d+:\d+:\d+,\d+)/;
18 | return r.test(content.slice(p1+1, 50));
19 | }
20 |
21 | export async function getEncoding(src: any) {
22 | const info = await languageEncoding(src);
23 | return info.encoding;
24 | }
25 |
26 | export function parseSrt(text: string) {
27 | const nodelist = parseSync(text);
28 | let idx = 1;
29 | const res: Node[] = [];
30 | for (const node of nodelist) {
31 | if (node.type === "cue" && node.data.text.trim().length > 0) {
32 | const t1 = formatTimestamp(node.data.start);
33 | const t2 = formatTimestamp(node.data.end);
34 | res.push({pos: String(idx), timestamp: `${t1} --> ${t2}`, content: node.data.text});
35 | idx++;
36 | }
37 | }
38 | return res;
39 | }
40 |
41 | function node_to_srt_text(node: Node, linesep: string): string {
42 | const jlst = [node.pos, node.timestamp]
43 | if (node.content && node.content.trim().length > 0) {
44 | jlst.push(node.content.trim());
45 | }
46 | return jlst.join(linesep);
47 | }
48 |
49 | export function nodesToSrtText(nodes: Node[], linesep="\n"): string {
50 | return nodes.map(node => node_to_srt_text(node, linesep)).join(linesep+linesep);
51 | }
52 |
53 | export function nodesToTransNodes(nodes: Node[], trans: string[], append=false, linesep="\n"): Node[] {
54 | return nodes.map((node, idx) => {
55 | const oldContent = append ? node.content + linesep: "";
56 | return {...node, content: oldContent + (trans[idx] || "")};
57 | });
58 | }
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import type { NextFetchEvent, NextRequest } from "next/server";
2 | import { digestMessage } from "./utils/fp";
3 |
4 | //const redis = Redis.fromEnv();
5 |
6 | export async function middleware(req: NextRequest, context: NextFetchEvent) {
7 | const { sentences, targetLang, srcLang, apiKey } = await req.json();
8 | let rkey = `${targetLang}_${srcLang}_${sentences}}`;
9 | rkey = "tranres_" + (await digestMessage(rkey));
10 | // const cached = await redis.get(rkey);
11 |
12 | // if (!isDev && cached) {
13 | // //if (cached) {
14 | // console.log("Using cached response " + rkey);
15 | // return NextResponse.json(cached);
16 | // }
17 |
18 | // licenseKeys
19 | // if (apiKey) {
20 | // if (checkOpenaiApiKeys(apiKey)) {
21 | // return NextResponse.next();
22 | // }
23 |
24 | // // // 3. something-invalid-sdalkjfasncs-key
25 | // if (!(await activateLicenseKey(apiKey, rkey.substring(8, 16)))) {
26 | // return NextResponse.redirect(new URL("/shop", req.url));
27 | // }
28 | // }
29 | // TODO: unique to a user (userid, email etc) instead of IP
30 | // const identifier = req.ip ?? "127.0.0.7";
31 | // const { success, remaining } = await ratelimit.limit("trans-" + identifier);
32 | // console.log(`======== ip ${identifier}, remaining: ${remaining} ========`);
33 | // if (!apiKey && !success) {
34 | // if (!apiKey) {
35 | // return NextResponse.redirect(new URL("/shop", req.url));
36 | // }
37 | }
38 |
39 | export const config = {
40 | matcher: "/api/translate",
41 | };
42 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const nextConfig = {
2 | reactStrictMode: true,
3 | exportPathMap: async function (defaultPathMap) {
4 | return {
5 | '/': { page: '/' },
6 | }
7 | },
8 | // 增加下面这项配置——关闭image自动优化
9 | images: {
10 | unoptimized: true,
11 | },
12 | };
13 | module.exports = nextConfig;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs2",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build && next export",
8 | "start": "serve out",
9 | "lint": "next lint",
10 | "test": "jest"
11 | },
12 | "dependencies": {
13 | "@ant-design/cssinjs": "^1.16.2",
14 | "@ant-design/icons": "^5.2.5",
15 | "@sentry/nextjs": "^7.42.0",
16 | "@types/node": "18.14.4",
17 | "@types/react": "18.0.28",
18 | "@types/react-dom": "18.0.11",
19 | "@vercel/analytics": "^0.1.11",
20 | "antd": "^5.8.2",
21 | "csstype": "3.0.10",
22 | "detect-file-encoding-and-language": "^2.3.2",
23 | "eslint": "8.35.0",
24 | "eslint-config-next": "13.2.3",
25 | "google-translate-api-x": "^10.5.4",
26 | "http-proxy-middleware": "^2.0.6",
27 | "next": "13.2.3",
28 | "react": "18.2.0",
29 | "react-dom": "18.2.0",
30 | "react-hot-toast": "^2.4.0",
31 | "react-use": "^17.4.0",
32 | "serve": "^14.2.0",
33 | "subtitle": "^4.2.1",
34 | "typescript": "4.9.5"
35 | },
36 | "devDependencies": {
37 | "@types/jest": "^29.4.0",
38 | "jest": "^29.5.0",
39 | "ts-jest": "^29.0.5"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 | import type { AppProps } from "next/app";
3 | import Head from "next/head";
4 | import HeaderNew from "@/components/new/HeaderNew";
5 | import FooterNew from "@/components/new/FooterNew";
6 | import { ConfigProvider } from "antd";
7 | import theme from "../utils/themeConfig";
8 |
9 | // function App({ Component, pageProps }: AppProps) {
10 | // return (
11 | //
12 | //
13 | //
AI字幕翻译
14 | //
15 | //
16 | //
17 | //
18 | //
19 | //
20 | //
21 | //
22 | // );
23 | // }
24 |
25 | // export default App;
26 |
27 | const App = ({ Component, pageProps }: AppProps) => (
28 |
29 |
30 |
AI字幕翻译
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 |
42 | export default App;
43 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {
2 | Html,
3 | Head,
4 | Main,
5 | NextScript,
6 | DocumentContext,
7 | } from "next/document";
8 | import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs";
9 |
10 | const MyDocument = () => {
11 | let description =
12 | "AI字幕翻译/格式转化小工具, translate subtilte, caption, close caption, using chatGPT AI, 企鹅交流群:812561075";
13 | //let ogimage = `${BASE_DOMAIN}/og-image.png`;
14 | let sitename = "ai.cgsv.top";
15 | let title = "AI字幕翻译 by jkhcc11/AISubtitle";
16 |
17 | return (
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | MyDocument.getInitialProps = async (ctx: DocumentContext) => {
41 | const cache = createCache();
42 | const originalRenderPage = ctx.renderPage;
43 | ctx.renderPage = () =>
44 | originalRenderPage({
45 | enhanceApp: (App) => (props) =>
46 | (
47 |
48 |
49 |
50 | ),
51 | });
52 |
53 | const initialProps = await Document.getInitialProps(ctx);
54 | const style = extractStyle(cache, true);
55 | return {
56 | ...initialProps,
57 | styles: (
58 | <>
59 | {initialProps.styles}
60 |
61 | >
62 | ),
63 | };
64 | };
65 |
66 | export default MyDocument;
67 |
--------------------------------------------------------------------------------
/pages/api/googleTran.ts:
--------------------------------------------------------------------------------
1 | import translate from 'google-translate-api-x';
2 | import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
3 | import { getLocale } from '@/lib/lang';
4 |
5 | // to like "en", "zh-CN"
6 | async function trans_texts(texts: string[], to: string) {
7 | const res = await translate(texts, {to: to});
8 | return res.map((item) => item.text);
9 | }
10 |
11 | export const config = {
12 | runtime: "edge",
13 | }
14 |
15 | export default async function handler(
16 | req: NextRequest,
17 | context: NextFetchEvent
18 | ) {
19 | const {sentences, targetLang, srcLang} = (await req.json()) as {
20 | sentences: string[];
21 | targetLang: string;
22 | srcLang?: string;
23 | }
24 | if (!sentences || sentences.length === 0) {
25 | return new Response("no subtitles", { status: 500 });
26 | }
27 |
28 | try {
29 | const resp = await trans_texts(sentences, getLocale(targetLang)!);
30 | return NextResponse.json(resp);
31 | } catch (error: any) {
32 | console.log("API error", error, error.message);
33 | return NextResponse.json({
34 | errorMessage: error.message,
35 | });
36 | }
37 | }
--------------------------------------------------------------------------------
/pages/api/proxy/[...slug].js:
--------------------------------------------------------------------------------
1 | import { createProxyMiddleware } from "http-proxy-middleware";
2 |
3 | export const config = {
4 | api: {
5 | bodyParser: false,
6 | },
7 | }
8 |
9 | export default createProxyMiddleware({
10 | target: "https://api.openai.com",
11 | changeOrigin: true,
12 | pathRewrite: {[`^/api/proxy`]: ''},
13 | });
--------------------------------------------------------------------------------
/pages/api/translate.ts:
--------------------------------------------------------------------------------
1 | import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
2 | import { getPayload, parse_gpt_resp } from "@/lib/openai/prompt";
3 | import { isDev } from "@/utils/env";
4 | import { OpenAIResult } from "@/lib/openai/OpenAIResult";
5 |
6 | export const config = {
7 | runtime: "edge",
8 | };
9 |
10 | // const redis = Redis.fromEnv();
11 |
12 | // if (!process.env.OPENAI_API_KEY) {
13 | // throw new Error("Missing env var from OpenAI");
14 | // }
15 |
16 | export default async function handler(
17 | req: NextRequest,
18 | context: NextFetchEvent
19 | ) {
20 | const {
21 | sentences,
22 | targetLang,
23 | srcLang,
24 | apiKey,
25 | promptTemplate,
26 | baseHost,
27 | gptModel,
28 | } = (await req.json()) as {
29 | sentences: string[];
30 | targetLang: string;
31 | srcLang?: string;
32 | apiKey?: string;
33 | promptTemplate?: string;
34 | baseHost?: string;
35 | gptModel?: string;
36 | };
37 | if (!sentences || sentences.length === 0) {
38 | return new Response("no subtitles", { status: 500 });
39 | }
40 |
41 | //获取请求体
42 | const payload = getPayload(
43 | sentences,
44 | targetLang,
45 | srcLang,
46 | promptTemplate,
47 | gptModel
48 | );
49 | const { res_keys } = payload;
50 |
51 | try {
52 | apiKey && console.log("=====use user api key=====");
53 | baseHost && console.log("=====use user custom host=====");
54 | isDev && console.log("payload", payload);
55 | const result = await OpenAIResult(payload, apiKey, baseHost);
56 | const resp = parse_gpt_resp(result, res_keys!);
57 |
58 | // let rkey = `${targetLang}_${srcLang}_${sentences}}`;
59 | // rkey = "tranres_" + await digestMessage(rkey);
60 | // const data = await redis.set(rkey, JSON.stringify(resp));
61 | // console.log("cached data", data);
62 |
63 | return NextResponse.json(resp);
64 | } catch (error: any) {
65 | console.log("API error", error, error.message);
66 | return NextResponse.json({
67 | errorMessage: error.message,
68 | });
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Srt from "./srt";
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
7 | // export async function getStaticProps({ locale }: {locale:string}) {
8 | // return {
9 | // props: {
10 | // ...(await serverSideTranslations(locale, ['common'], nextI18nextConfig)),
11 | // }
12 | // }
13 | // }
14 |
--------------------------------------------------------------------------------
/pages/srt.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | TranslationOutlined,
4 | SettingOutlined,
5 | InfoCircleOutlined,
6 | } from "@ant-design/icons";
7 | import { Tabs } from "antd";
8 | import Setting from "@/components/srt/Setting";
9 | import About from "@/components/srt/About";
10 | import SrtNew from "@/components/srt/SrtNew";
11 | // import SubtitleLineNew from "@/components/new/SubtitleLineNew";
12 |
13 | const tabItems = [
14 | {
15 | label: (
16 |
17 |
18 | 翻译
19 |
20 | ),
21 | key: "main",
22 | children: ,
23 | },
24 | {
25 | label: (
26 |
27 |
28 | 设置
29 |
30 | ),
31 | key: "setting",
32 | children: ,
33 | },
34 | {
35 | label: (
36 |
37 |
38 | 关于说明
39 |
40 | ),
41 | key: "about",
42 | children: ,
43 | },
44 | // {
45 | // label: Test ,
46 | // key: "test",
47 | // children: ,
48 | // },
49 | ];
50 |
51 | const Srt: React.FC = () => (
52 |
53 |
60 | 支持翻译本地SRT/ASS格式字幕 Powered by OpenAI GPT-3.5
61 |
62 |
63 |
64 |
65 | );
66 |
67 | export default Srt;
68 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jkhcc11/AISubtitle/9af3a3bf7ee4524cac7cc2be03734c75d7dd184a/public/favicon.ico
--------------------------------------------------------------------------------
/public/loading.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/locales/en/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "AI-Subtilte": "AI Subtitle",
3 | "select-local-sub": "Select subtitle",
4 | "prev": "Prev",
5 | "next": "Next",
6 | "timestamp": "Timestamp",
7 | "original": "Orginal",
8 | "translated": "Translated",
9 | "targetLang": "Target lang",
10 | "Chinese": "Chinese",
11 | "English": "English",
12 | "Japanese": "Japanese",
13 | "Korean": "Korean",
14 | "Spanish": "Spanish",
15 | "Translate-This": "Translate this page",
16 | "Translate-File": "Translate file",
17 | "Download-Original": "Download original",
18 | "Download-Translated": "Download translated",
19 | "Bili-Url": "Full Youtube video URL",
20 | "Bili-Get-Sub": "Get video subs",
21 | "Welcome": "Translate SRT/ASS files\nPowered by OpenAI GPT-3.5",
22 | "Request-Key": "Please enter your OpenAI API key, like sk-xxxx ",
23 | "API-Slow-Warn": "OpenAI API may be slow, please wait",
24 | "Progress": "IP ",
25 | "ShopTip": "{} free uses per day. Click to --- purchase --- more💰"
26 | }
--------------------------------------------------------------------------------
/public/locales/zh-CN/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "AI-Subtilte": "AI字幕助手",
3 | "select-local-sub": "选择本地字幕",
4 | "prev": "上一页",
5 | "next": "下一页",
6 | "timestamp": "时间戳",
7 | "original": "原文",
8 | "translated": "译文",
9 | "targetLang": "目标语言",
10 | "Chinese": "中文",
11 | "English": "英文",
12 | "Japanese": "日语",
13 | "Korean": "韩语",
14 | "Spanish": "西班牙语",
15 | "Translate-This": "翻译本页",
16 | "Translate-File": "翻译整个文件",
17 | "Download-Original": "下载原文字幕",
18 | "Download-Translated": "下载译文字幕",
19 | "Bili-Url": "输入完整B站/油管视频链接",
20 | "Bili-Get-Sub": "获取网站字幕",
21 | "Welcome": "支持翻译本地SRT/ASS格式字幕\nPowered by OpenAI GPT-3.5",
22 | "Request-Key": "请输入你的OpenAI API key,如 sk-xxxx ",
23 | "translate file successfully": "翻译字幕文件成功",
24 | "translate file failed ": "翻译字幕文件失败",
25 | "Max file size 512KB": "最大支持512KB文件",
26 | "Cannot open as text file": "无法以文本打开",
27 | "translate failed": "翻译失败",
28 | "do not support this video url": "暂不支持此视频链接",
29 | "Input Youtube/Bilibili video full url": "请输入B站/油管视频长链接,暂不支持b23.tv或av号",
30 | "Get video subtitle failed": "获取视频字幕失败",
31 | "API-Slow-Warn": "OpenAI接口可能较慢,请耐心等待",
32 | "No subtitle selected": "未选择字幕",
33 | "Progress": "进度",
34 | "OpenAI API key successfully set":"成功设置API Key",
35 | "OpenAI API key is invalid":"API key无效",
36 | "Show All languages": "显示所有语言",
37 | "ShopTip": "次数用完啦!每天能用 {} 次,请点击 --- 购买 --- 次数哦💰"
38 | }
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/openai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jkhcc11/AISubtitle/9af3a3bf7ee4524cac7cc2be03734c75d7dd184a/public/openai.png
--------------------------------------------------------------------------------
/public/set1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jkhcc11/AISubtitle/9af3a3bf7ee4524cac7cc2be03734c75d7dd184a/public/set1.png
--------------------------------------------------------------------------------
/public/setting_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jkhcc11/AISubtitle/9af3a3bf7ee4524cac7cc2be03734c75d7dd184a/public/setting_1.png
--------------------------------------------------------------------------------
/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/sentry.client.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the browser.
2 | // The config you add here will be used whenever a page is visited.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs';
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN,
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | });
18 |
--------------------------------------------------------------------------------
/sentry.edge.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever middleware or an Edge route handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs';
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN,
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | });
18 |
--------------------------------------------------------------------------------
/sentry.properties:
--------------------------------------------------------------------------------
1 | defaults.url=https://sentry.io/
2 | defaults.org=cgsv
3 | defaults.project=javascript-nextjs
4 | cli.executable=node_modules/@sentry/cli/bin/sentry-cli
5 |
--------------------------------------------------------------------------------
/sentry.server.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs';
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN,
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | });
18 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 6rem;
7 | min-height: 100vh;
8 | }
9 |
10 | .description {
11 | display: inherit;
12 | justify-content: inherit;
13 | align-items: inherit;
14 | font-size: 0.85rem;
15 | max-width: var(--max-width);
16 | width: 100%;
17 | z-index: 2;
18 | font-family: var(--font-mono);
19 | }
20 |
21 | .description a {
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | gap: 0.5rem;
26 | }
27 |
28 | .description p {
29 | position: relative;
30 | margin: 0;
31 | padding: 1rem;
32 | background-color: rgba(var(--callout-rgb), 0.5);
33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3);
34 | border-radius: var(--border-radius);
35 | }
36 |
37 | .code {
38 | font-weight: 700;
39 | font-family: var(--font-mono);
40 | }
41 |
42 | .grid {
43 | display: grid;
44 | grid-template-columns: repeat(4, minmax(25%, auto));
45 | width: var(--max-width);
46 | max-width: 100%;
47 | }
48 |
49 | .card {
50 | padding: 1rem 1.2rem;
51 | border-radius: var(--border-radius);
52 | background: rgba(var(--card-rgb), 0);
53 | border: 1px solid rgba(var(--card-border-rgb), 0);
54 | transition: background 200ms, border 200ms;
55 | }
56 |
57 | .card span {
58 | display: inline-block;
59 | transition: transform 200ms;
60 | }
61 |
62 | .card h2 {
63 | font-weight: 600;
64 | margin-bottom: 0.7rem;
65 | }
66 |
67 | .card p {
68 | margin: 0;
69 | opacity: 0.6;
70 | font-size: 0.9rem;
71 | line-height: 1.5;
72 | max-width: 30ch;
73 | }
74 |
75 | .center {
76 | display: flex;
77 | justify-content: center;
78 | align-items: center;
79 | position: relative;
80 | padding: 4rem 0;
81 | }
82 |
83 | .center::before {
84 | background: var(--secondary-glow);
85 | border-radius: 50%;
86 | width: 480px;
87 | height: 360px;
88 | margin-left: -400px;
89 | }
90 |
91 | .center::after {
92 | background: var(--primary-glow);
93 | width: 240px;
94 | height: 180px;
95 | z-index: -1;
96 | }
97 |
98 | .center::before,
99 | .center::after {
100 | content: '';
101 | left: 50%;
102 | position: absolute;
103 | filter: blur(45px);
104 | transform: translateZ(0);
105 | }
106 |
107 | .logo,
108 | .thirteen {
109 | position: relative;
110 | }
111 |
112 | .thirteen {
113 | display: flex;
114 | justify-content: center;
115 | align-items: center;
116 | width: 75px;
117 | height: 75px;
118 | padding: 25px 10px;
119 | margin-left: 16px;
120 | transform: translateZ(0);
121 | border-radius: var(--border-radius);
122 | overflow: hidden;
123 | box-shadow: 0px 2px 8px -1px #0000001a;
124 | }
125 |
126 | .thirteen::before,
127 | .thirteen::after {
128 | content: '';
129 | position: absolute;
130 | z-index: -1;
131 | }
132 |
133 | /* Conic Gradient Animation */
134 | .thirteen::before {
135 | animation: 6s rotate linear infinite;
136 | width: 200%;
137 | height: 200%;
138 | background: var(--tile-border);
139 | }
140 |
141 | /* Inner Square */
142 | .thirteen::after {
143 | inset: 0;
144 | padding: 1px;
145 | border-radius: var(--border-radius);
146 | background: linear-gradient(
147 | to bottom right,
148 | rgba(var(--tile-start-rgb), 1),
149 | rgba(var(--tile-end-rgb), 1)
150 | );
151 | background-clip: content-box;
152 | }
153 |
154 | /* Enable hover only on non-touch devices */
155 | @media (hover: hover) and (pointer: fine) {
156 | .card:hover {
157 | background: rgba(var(--card-rgb), 0.1);
158 | border: 1px solid rgba(var(--card-border-rgb), 0.15);
159 | }
160 |
161 | .card:hover span {
162 | transform: translateX(4px);
163 | }
164 | }
165 |
166 | @media (prefers-reduced-motion) {
167 | .thirteen::before {
168 | animation: none;
169 | }
170 |
171 | .card:hover span {
172 | transform: none;
173 | }
174 | }
175 |
176 | /* Mobile */
177 | @media (max-width: 700px) {
178 | .content {
179 | padding: 4rem;
180 | }
181 |
182 | .grid {
183 | grid-template-columns: 1fr;
184 | margin-bottom: 120px;
185 | max-width: 320px;
186 | text-align: center;
187 | }
188 |
189 | .card {
190 | padding: 1rem 2.5rem;
191 | }
192 |
193 | .card h2 {
194 | margin-bottom: 0.5rem;
195 | }
196 |
197 | .center {
198 | padding: 8rem 0 6rem;
199 | }
200 |
201 | .center::before {
202 | transform: none;
203 | height: 300px;
204 | }
205 |
206 | .description {
207 | font-size: 0.8rem;
208 | }
209 |
210 | .description a {
211 | padding: 1rem;
212 | }
213 |
214 | .description p,
215 | .description div {
216 | display: flex;
217 | justify-content: center;
218 | position: fixed;
219 | width: 100%;
220 | }
221 |
222 | .description p {
223 | align-items: center;
224 | inset: 0 0 auto;
225 | padding: 2rem 1rem 1.4rem;
226 | border-radius: 0;
227 | border: none;
228 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
229 | background: linear-gradient(
230 | to bottom,
231 | rgba(var(--background-start-rgb), 1),
232 | rgba(var(--callout-rgb), 0.5)
233 | );
234 | background-clip: padding-box;
235 | backdrop-filter: blur(24px);
236 | }
237 |
238 | .description div {
239 | align-items: flex-end;
240 | pointer-events: none;
241 | inset: auto 0 0;
242 | padding: 2rem;
243 | height: 200px;
244 | background: linear-gradient(
245 | to bottom,
246 | transparent 0%,
247 | rgb(var(--background-end-rgb)) 40%
248 | );
249 | z-index: 1;
250 | }
251 | }
252 |
253 | /* Tablet and Smaller Desktop */
254 | @media (min-width: 701px) and (max-width: 1120px) {
255 | .grid {
256 | grid-template-columns: repeat(2, 50%);
257 | }
258 | }
259 |
260 | @media (prefers-color-scheme: dark) {
261 | .vercelLogo {
262 | filter: invert(1);
263 | }
264 |
265 | .logo,
266 | .thirteen img {
267 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
268 | }
269 | }
270 |
271 | @keyframes rotate {
272 | from {
273 | transform: rotate(360deg);
274 | }
275 | to {
276 | transform: rotate(0deg);
277 | }
278 | }
279 |
--------------------------------------------------------------------------------
/styles/Srt.module.css:
--------------------------------------------------------------------------------
1 | .genButton {
2 | background-color: rgb(56, 189, 248);
3 | color: whitesmoke;
4 | border: 0;
5 | border-radius: 5px;
6 | transition-property: all;
7 | transition-duration: 0.15s;
8 | padding-left: 10px;
9 | padding-right: 10px;
10 | }
11 |
12 | .genButton:hover {
13 | background-color: rgb(14, 165, 233);
14 | }
15 |
16 |
17 | /* prev next page */
18 | .navButton {
19 | background-color: rgb(56, 189, 248);
20 | width: 60px;
21 | height: 30px;
22 | margin: 10px;
23 | color: whitesmoke;
24 | border: 0;
25 | border-radius: 5px;
26 | transition-property: all;
27 | transition-duration: 0.15s;
28 | }
29 |
30 | .navButton:hover {
31 | background-color: rgb(14, 165, 233);
32 | }
33 |
34 | .file {
35 | position: relative;
36 | display: inline-block;
37 | background: #D0EEFF;
38 | border: 1px solid #99D3F5;
39 | border-radius: 4px;
40 | padding: 4px 12px;
41 | overflow: hidden;
42 | color: #1E88C7;
43 | text-decoration: none;
44 | text-indent: 0;
45 | line-height: 20px;
46 | }
47 | .file input {
48 | position: absolute;
49 | font-size: 100px;
50 | right: 0;
51 | top: 0;
52 | opacity: 0;
53 | }
54 | .file:hover {
55 | background: #AADFFD;
56 | border-color: #78C3F3;
57 | color: #004974;
58 | text-decoration: none;
59 | }
60 |
61 | .biliInput {
62 | border-radius: 6px;
63 | background-color: transparent;
64 | }
65 |
66 | .selectLang {
67 | background-color: #D0EEFF;
68 | border-radius: 5px;
69 | width: 80px;
70 | height: 30px;
71 | }
72 |
73 | .welcomeMessage {
74 | font-size: 30px;
75 | white-space: pre-wrap;
76 | text-align: center;
77 | font-weight: 600;
78 | color:#004974;
79 | }
--------------------------------------------------------------------------------
/styles/SubtitleLine.module.css:
--------------------------------------------------------------------------------
1 |
2 | .timestampBlock {
3 | display: flex;
4 | width: 100px;
5 | min-height: 60px;
6 | justify-content: center;
7 | align-items: center;
8 | background-color:bisque;
9 | font-size: 15px;
10 | line-height: 20px;
11 | text-align: center;
12 | color: blueviolet;
13 | }
14 |
15 | .contentBlock {
16 | display: flex;
17 | width: 350px;
18 | min-height: 60px;
19 | padding-left: 10px;
20 | padding-right: 10px;
21 | align-items: center;
22 | background-color: aqua;
23 | flex: 0 auto;
24 | font-size: 15px;
25 | text-align: left;
26 | white-space: pre-wrap;
27 | }
28 | .transBlock {
29 | display: flex;
30 | width: 350px;
31 | min-height: 60px;
32 | padding-left: 10px;
33 | padding-right: 10px;
34 | align-items: center;
35 | background-color:aliceblue;
36 | flex: 0 auto;
37 | font-size: 15px;
38 | text-align: left;
39 | white-space: pre-wrap;
40 | }
41 |
42 | .lineContainer {
43 | display: flex;
44 | justify-content: center;
45 | border-top: 1px solid transparent;
46 | }
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | padding: 0;
4 | margin: 0;
5 | }
6 |
7 | html,
8 | body {
9 | width: 90%;
10 | overflow-x: scroll;
11 | margin: 0 auto;
12 | }
13 |
14 | body {
15 | color: rgb(var(--foreground-rgb));
16 | background: linear-gradient(
17 | to bottom,
18 | transparent,
19 | rgb(var(--background-end-rgb))
20 | )
21 | rgb(var(--background-start-rgb));
22 | }
23 |
24 | a {
25 | color: inherit;
26 | text-decoration: none;
27 | }
28 |
29 | /* @media (prefers-color-scheme: dark) {
30 | html {
31 | color-scheme: dark;
32 | }
33 | } */
34 |
--------------------------------------------------------------------------------
/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 | "paths": {
18 | "@/*": ["./*"]
19 | }
20 | },
21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
22 | "exclude": ["node_modules"]
23 | }
24 |
--------------------------------------------------------------------------------
/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const RATE_LIMIT_COUNT = 5;
2 | export const CHECKOUT_URL =
3 | "https://coolapps.lemonsqueezy.com/checkout/buy/f53d875a-4424-4ea3-8398-f3559dfaef98";
4 | export const ENABLE_SHOP = process.env.NEXT_PUBLIC_ENABLE_SHOP === "true";
5 | export const DEFAULT_PROMPT =
6 | "你是一个专业的翻译。请逐行翻译下面的文本到{{target_lang}},注意保留数字和换行符,请勿自行创建内容,除了翻译,不要输出任何其他文本。";
7 | export const DEFAULT_BASE_URL_HOST = "https://api.openai.com";
8 |
9 | //cache key
10 | export const CacheKey = {
11 | UserApikeyWithOpenAi: "user-openai-apikey-trans",
12 | UserBaseHostWithOpenAi: "user-openai-host",
13 | UserCustomSetting: "user-custom-setting",
14 | };
15 |
16 | /**
17 | * 缓存Item
18 | */
19 | export interface CustomConfigItem {
20 | customHost?: string;
21 | customModel: string;
22 | apiKey?: string;
23 | promptTemplate: string;
24 | delaySecond?: number;
25 | }
26 |
27 | /**
28 | * 获取自定义配置缓存
29 | * @returns
30 | */
31 | export const getCustomConfigCache = (): CustomConfigItem => {
32 | const res = localStorage.getItem(CacheKey.UserCustomSetting);
33 | if (res) return JSON.parse(res) as CustomConfigItem;
34 | return {
35 | customModel: "gpt-3.5-turbo-16k",
36 | promptTemplate:
37 | "你是一个专业的翻译。请逐行翻译下面的文本到{{target_lang}},注意保留数字和换行符,请勿自行创建内容,除了翻译,不要输出任何其他文本。",
38 | } as CustomConfigItem;
39 | };
40 |
41 | export const openAiErrorCode = {
42 | context_length_exceeded: "当前内容太多,请调整分页大小",
43 | account_deactivated: "ApiKey已封禁",
44 | invalid_request_error: "ApiKey已失效或已被删除",
45 | };
46 |
--------------------------------------------------------------------------------
/utils/editTableHook.tsx:
--------------------------------------------------------------------------------
1 | import type { FormInstance } from "antd/es/form";
2 | import { Button, Form, Input, Popconfirm, Table } from "antd";
3 | import React, { useContext, useEffect, useRef, useState } from "react";
4 | import type { InputRef } from "antd";
5 |
6 | //编辑上下文
7 | const EditableContext = React.createContext | null>(null);
8 | interface EditableRowProps {
9 | index: number;
10 | }
11 |
12 | //编辑单元格
13 | export const EditableRow: React.FC = ({
14 | index,
15 | ...props
16 | }) => {
17 | const [form] = Form.useForm();
18 | return (
19 |
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 {childNode} ;
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 |
--------------------------------------------------------------------------------