├── .eslintrc.json ├── .gitattributes ├── public ├── logo.png ├── vercel.svg └── next.svg ├── .dockerignore ├── src ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── page.tsx │ ├── not-found.tsx │ ├── utils │ │ ├── DataContext.tsx │ │ ├── localStorageUtils.ts │ │ ├── copyToClipboard.tsx │ │ ├── fileUtils.ts │ │ ├── regex.ts │ │ ├── index.ts │ │ ├── jsonUtils.ts │ │ ├── jsonPathUtils.ts │ │ └── textUtils.ts │ ├── [locale] │ │ ├── page.tsx │ │ ├── not-found.tsx │ │ ├── layout.tsx │ │ ├── client.tsx │ │ ├── subtitleUtils.ts │ │ └── SubtitleTranslator.tsx │ ├── globals.css │ ├── hooks │ │ ├── useTranslations.tsx │ │ ├── useCopyToClipboard.tsx │ │ └── useFileUpload.ts │ ├── ThemesProvider.tsx │ ├── components │ │ ├── KeyMappingInput.tsx │ │ ├── languages.tsx │ │ ├── projects.tsx │ │ └── TranslationSettings.tsx │ ├── api │ │ └── deepl │ │ │ └── route.ts │ └── ui │ │ └── Navigation.tsx ├── i18n │ ├── navigation.ts │ ├── request.ts │ └── routing.ts └── middleware.ts ├── postcss.config.mjs ├── next-sitemap.config.js ├── .gitignore ├── tailwind.config.ts ├── docker-entrypoint.sh ├── tsconfig.json ├── Dockerfile ├── next.config.mjs ├── LICENSE ├── package.json ├── .github └── workflows │ └── docker.yml ├── scripts └── buildWithLang.js ├── README-zh.md ├── README.md └── messages ├── zh.json ├── zh-hant.json ├── ja.json └── ko.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockbenben/subtitle-translator/HEAD/public/logo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | node_modules 4 | out 5 | *.log 6 | .DS_Store 7 | coverage 8 | # .next 9 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockbenben/subtitle-translator/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function RootLayout({ children }: { children: React.ReactNode }) { 4 | return <>{children}; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | // Redirect the user to the default locale when `/` is requested 4 | export default function RootPage() { 5 | redirect("/en"); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | // Redirect the user to the default locale when `/` is requested 4 | export default function RootNotFound() { 5 | redirect("/en"); 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /src/i18n/navigation.ts: -------------------------------------------------------------------------------- 1 | import { createNavigation } from "next-intl/navigation"; 2 | import { routing } from "./routing"; 3 | 4 | // Lightweight wrappers around Next.js' navigation 5 | // APIs that consider the routing configuration 6 | export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); 7 | -------------------------------------------------------------------------------- /src/app/utils/DataContext.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { createContext, ReactNode } from "react"; 3 | 4 | export const DataContext = createContext(null); 5 | 6 | export function DataProvider({ children, data }: { children: ReactNode; data: any }) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import createMiddleware from "next-intl/middleware"; 2 | import { routing } from "./i18n/routing"; 3 | 4 | export default createMiddleware(routing); 5 | 6 | export const config = { 7 | // Match all pathnames except for 8 | // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` 9 | // - … the ones containing a dot (e.g. `favicon.ico`) 10 | matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)", 11 | }; 12 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: process.env.SITE_URL || "https://tools.newzone.top", 4 | generateRobotsTxt: true, // 索引站点地图 robots.txt 5 | outDir: "out", // 站点地图输出目录 6 | // sitemapSize: 7000, // 大型站点可拆分成多个 sitemap 文件 7 | // 将其他站点地图添加到 robots.txt 主机条目的选项 8 | /* robotsTxtOptions: { 9 | additionalSitemaps: ["https://example.com/server-sitemap.xml"], 10 | }, */ 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientPage from "./client"; 2 | import { getTranslations } from "next-intl/server"; 3 | 4 | export async function generateMetadata({ params }) { 5 | const { locale } = await params; 6 | const t = await getTranslations({ locale, namespace: "subtitle" }); 7 | 8 | return { 9 | title: `${t("title")} - Tools by AI`, 10 | description: t("description"), 11 | }; 12 | } 13 | 14 | export default function Page() { 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /src/i18n/request.ts: -------------------------------------------------------------------------------- 1 | import { getRequestConfig } from "next-intl/server"; 2 | import { hasLocale } from "next-intl"; 3 | import { routing } from "./routing"; 4 | 5 | export default getRequestConfig(async ({ requestLocale }) => { 6 | // Typically corresponds to the `[locale]` segment 7 | const requested = await requestLocale; 8 | const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; 9 | 10 | return { 11 | locale, 12 | messages: (await import(`../../messages/${locale}.json`)).default, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /.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 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # tauri 38 | src-tauri -------------------------------------------------------------------------------- /src/app/utils/localStorageUtils.ts: -------------------------------------------------------------------------------- 1 | export const loadFromLocalStorage = (key: string) => { 2 | const storedValue = localStorage.getItem(key); 3 | if (storedValue === null) return null; 4 | 5 | try { 6 | return JSON.parse(storedValue); 7 | } catch { 8 | return null; // 避免返回无法解析的原始字符串 9 | } 10 | }; 11 | 12 | export const saveToLocalStorage = (key: string, value: any) => { 13 | try { 14 | localStorage.setItem(key, JSON.stringify(value)); 15 | } catch (error) { 16 | console.error(`Error saving key "${key}" to localStorage:`, error); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: ["./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}"], 5 | theme: { 6 | extend: { 7 | backgroundImage: { 8 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 9 | "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 10 | }, 11 | }, 12 | }, 13 | important: true, 14 | plugins: [], 15 | }; 16 | export default config; 17 | -------------------------------------------------------------------------------- /src/i18n/routing.ts: -------------------------------------------------------------------------------- 1 | import { defineRouting } from "next-intl/routing"; 2 | import { createNavigation } from "next-intl/navigation"; 3 | 4 | export const routing = defineRouting({ 5 | // A list of all locales that are supported 6 | locales: ["en", "zh", "zh-hant", "pt", "es", "hi", "ar", "fr", "de", "ja", "ko", "ru", "vi", "tr", "bn", "id", "it"], 7 | defaultLocale: "en", 8 | }); 9 | 10 | // Lightweight wrappers around Next.js' navigation APIs 11 | // that will consider the routing configuration 12 | export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); 13 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Check if wget exists 4 | command -v wget >/dev/null 2>&1 || { echo >&2 "wget not found!"; exit 1; } 5 | 6 | # Start the dev server in the background 7 | yarn dev & 8 | DEV_PID=$! 9 | 10 | # Wait until the app is ready 11 | npx wait-on http://localhost:3000 12 | sleep 2 13 | 14 | # Route language list 15 | langs="en zh zh-hant pt es hi ar fr de ja ko ru vi tr bn id it" 16 | 17 | for lang in $langs; do 18 | echo "Warming up /$lang" 19 | wget --timeout=5 --tries=1 -qO- "http://localhost:3000/$lang" > /dev/null || true 20 | done 21 | 22 | # Keep the container running 23 | wait $DEV_PID 24 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } */ 28 | -------------------------------------------------------------------------------- /src/app/hooks/useTranslations.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslations } from "next-intl"; 2 | import { languages } from "@/app/components/languages"; 3 | 4 | export const useLanguageOptions = () => { 5 | const t = useTranslations(); 6 | 7 | // Create source options with translations 8 | const sourceOptions = languages.map((language) => ({ 9 | ...language, 10 | label: `${t(language.labelKey)} (${language.nativeLabel})`, 11 | })); 12 | 13 | // Create target options with translations (excluding "auto") 14 | const targetOptions = languages 15 | .filter((language) => language.value !== "auto") 16 | .map((language) => ({ 17 | ...language, 18 | label: `${t(language.labelKey)} (${language.nativeLabel})`, 19 | })); 20 | 21 | return { sourceOptions, targetOptions }; 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 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 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | }, 25 | "strictNullChecks": false 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 基础镜像 2 | FROM node:20-alpine 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 复制项目中的 package.json 和 yarn.lock 到工作目录中 8 | COPY package.json yarn.lock ./ 9 | 10 | # 安装依赖,并清理缓存 11 | RUN yarn install --frozen-lockfile --network-timeout 100000 && \ 12 | yarn add -D wait-on && \ 13 | yarn cache clean 14 | 15 | # 复制项目源代码到工作目录 16 | COPY . . 17 | 18 | # 设置环境变量 19 | ENV NODE_ENV=development 20 | ENV NEXT_TELEMETRY_DISABLED=1 21 | 22 | # 暴露端口 3000 23 | EXPOSE 3000 24 | 25 | # 拷贝并设置启动脚本 26 | COPY docker-entrypoint.sh /entrypoint.sh 27 | RUN chmod +x /entrypoint.sh 28 | 29 | # 使用自定义启动脚本 30 | ENTRYPOINT ["/entrypoint.sh"] 31 | 32 | # 最终命令:再次启动开发服务器 33 | #CMD ["yarn", "dev"] 34 | 35 | # 容器构建&运行命令 36 | # docker build -t subtitle-translator . 37 | # docker run -d -p 3000:3000 --name subtitle-translator subtitle-translator -------------------------------------------------------------------------------- /src/app/utils/copyToClipboard.tsx: -------------------------------------------------------------------------------- 1 | import { message } from "antd"; 2 | // for zh 3 | export const copyToClipboard = async (text: string, messageApi?: ReturnType[0], targetText?: string) => { 4 | // 处理空内容情况 5 | if (!text || text.trim() === "") { 6 | messageApi?.warning(targetText ? `${targetText} 内容为空,无需复制` : "目标内容为空,无需复制"); 7 | return; 8 | } 9 | 10 | // 检查剪贴板 API 可用性 11 | if (!navigator?.clipboard) { 12 | messageApi?.error("当前浏览器不支持剪贴板操作,请尝试手动复制或使用其他浏览器"); 13 | return; 14 | } 15 | 16 | try { 17 | await navigator.clipboard.writeText(text); 18 | messageApi?.success(targetText ? `${targetText} 已复制` : "文本已复制"); 19 | } catch (err) { 20 | console.error("复制到剪贴板失败:", err); 21 | messageApi?.error(targetText ? `${targetText} 复制失败,请手动复制` : "复制失败,请手动复制内容"); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from "file-saver"; 2 | /** 3 | * 下载文件工具函数 4 | * @param {string|Blob|ArrayBuffer} content - 要下载的文件内容 5 | * @param {string} fileName - 下载文件的名称 6 | * @param {string} mimeType - 文件 MIME 类型,默认为"text/plain;charset=utf-8" 7 | * @returns {void} 8 | */ 9 | export const downloadFile = (content, fileName, mimeType = "text/plain;charset=utf-8") => { 10 | return new Promise((resolve, reject) => { 11 | try { 12 | // 创建 Blob(如果内容不是 Blob) 13 | const fileBlob = content instanceof Blob ? content : new Blob([content], { type: mimeType }); 14 | saveAs(fileBlob, fileName); 15 | // 添加一个小延迟以确保浏览器有时间处理下载 16 | setTimeout(() => { 17 | resolve(fileName); 18 | }, 100); 19 | } catch (error) { 20 | console.error("File download failed: ", error); 21 | reject(error); 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/app/ThemesProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"; 4 | import { ConfigProvider, theme } from "antd"; 5 | import { ReactNode } from "react"; 6 | import { AntdRegistry } from "@ant-design/nextjs-registry"; 7 | 8 | export default function ThemesProvider({ children }: { children: ReactNode }) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | function AntdConfigProvider({ children }: { children: ReactNode }) { 17 | const { theme: currentTheme } = useTheme(); 18 | 19 | const algorithms = currentTheme === "dark" ? [theme.darkAlgorithm] : [theme.defaultAlgorithm]; 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/utils/regex.ts: -------------------------------------------------------------------------------- 1 | // 标点符号结尾的正则表达式 2 | export const punctuationEndRegex = /(?:[。?!…”"」\]】))※]|\.{3,}|-{3,}|—{3,}|={3,}|={3,})$/; 3 | // 特殊开头字符的正则,未包含括号 (( 4 | export const specialLineStartRegex = /^(?:[【「\[“"◆※]|-{3,}|—{3,}|={3,}|={3,}|第.*[章节卷])$/; 5 | 6 | // 纯数字行的正则表达式 7 | export const pureNumberRegex = /^\d+$/; 8 | // 数字开头 9 | export const numberStartRegex = /^\d+/; 10 | // 数字结尾 11 | export const numberEndRegex = /\d$/; 12 | 13 | // 中文小说常用正则 14 | // 章节标题正则 15 | export const chapterTitleRegex = 16 | /^(序章|序言|引子|前言|卷首语|扉页|楔子|正文|终章|后记|附录|尾声|番外|[上中下][部册]册|第?\s{0,4}[\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|幕(?![前后布])|回(?![合访忆顾应答音])|部(?![分赛游])|篇(?!张))).*/; 17 | 18 | // 数字标题正则 19 | export const numberTitleRegex = /^[  \t]{0,4}\d{1,5}([::,., 、_—\-]|【.{1,30}】).{0,30}$/; 20 | 21 | // 标题严格正则:第 X 章 22 | export const chapterPattern = /^(第?\s{0,4}[\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\s{0,4}章)(.*)$/; 23 | 24 | export const novelSectionHeaderRegex = /^(?:作者|(?:内容|作品)?简介|创作|标签)[::]?/u; 25 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import createNextIntlPlugin from "next-intl/plugin"; 2 | const withNextIntl = createNextIntlPlugin(); 3 | 4 | // This file is used to configure Static Next.js for the Tauri app. 5 | const isProd = process.env.NODE_ENV === "production"; 6 | const internalHost = process.env.TAURI_DEV_HOST; 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | basePath: "", 11 | // Ensure Next.js uses SSG instead of SSR 12 | // https://nextjs.org/docs/pages/building-your-application/deploying/static-exports 13 | output: "export", 14 | // Note: This feature is required to use the Next.js Image component in SSG mode. 15 | // See https://nextjs.org/docs/messages/export-image-api for different workarounds. 16 | images: { 17 | unoptimized: true, 18 | }, 19 | // Dynamically set assetPrefix based on environment 20 | assetPrefix: isProd 21 | ? "/" // production 22 | : internalHost 23 | ? `http://${internalHost}:3000` // dev + TAURI_DEV_HOST provided 24 | : "/", // dev + no TAURI_DEV_HOST 25 | }; 26 | 27 | export default withNextIntl(nextConfig); 28 | -------------------------------------------------------------------------------- /src/app/[locale]/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Result } from "antd"; 4 | import { useRouter } from "next/navigation"; 5 | import { useLocale } from "next-intl"; 6 | import { useEffect, useState } from "react"; 7 | 8 | export default function NotFound() { 9 | const router = useRouter(); 10 | const locale = useLocale(); 11 | const homePath = `/${locale}`; 12 | const [countdown, setCountdown] = useState(3); 13 | 14 | useEffect(() => { 15 | const timer = setTimeout(() => { 16 | router.push(homePath); 17 | }, 3000); 18 | 19 | // 倒计时显示 20 | const countdownInterval = setInterval(() => { 21 | setCountdown((prev) => prev - 1); 22 | }, 1000); 23 | 24 | return () => { 25 | clearTimeout(timer); 26 | clearInterval(countdownInterval); 27 | }; 28 | }, [router, homePath]); 29 | 30 | return ( 31 |
32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 rockbenben 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/hooks/useCopyToClipboard.tsx: -------------------------------------------------------------------------------- 1 | import { message } from "antd"; 2 | import { useTranslations } from "next-intl"; 3 | 4 | export const useCopyToClipboard = () => { 5 | const t = useTranslations("CopyToClipboard"); 6 | 7 | const copyToClipboard = async (text: string, messageApi?: ReturnType[0], targetText?: string) => { 8 | if (!text || text.trim() === "") { 9 | const warningMsg = targetText ? `${targetText}${t("empty")}` : t("empty"); 10 | messageApi?.warning(warningMsg); 11 | return; 12 | } 13 | 14 | if (!navigator?.clipboard) { 15 | messageApi?.error(t("unsupported")); 16 | return; 17 | } 18 | 19 | try { 20 | await navigator.clipboard.writeText(text); 21 | const successMsg = targetText ? `${targetText}${t("success")}` : t("success"); 22 | messageApi?.success(successMsg); 23 | } catch (err) { 24 | console.error("Copy to clipboard failed: ", err); 25 | const errorMsg = targetText ? `${targetText}${t("failure")}` : t("failure"); 26 | messageApi?.error(errorMsg); 27 | } 28 | }; 29 | 30 | return { copyToClipboard }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitTextIntoLines, 3 | cleanLines, 4 | truncate, 5 | getTextStats, 6 | splitParagraph, 7 | toHalfWidth, 8 | filterLines, 9 | removeAdjacentDuplicateLines, 10 | normalizeNewlines, 11 | dedupeLines, 12 | compressNewlines, 13 | splitBySpaces, 14 | parseSpaceSeparatedItems, 15 | } from "./textUtils"; 16 | import { copyToClipboard } from "./copyToClipboard"; 17 | import { downloadFile } from "./fileUtils"; 18 | import { DataContext, DataProvider } from "./DataContext"; 19 | import { preprocessJson, stripJsonWrapper } from "./jsonUtils"; 20 | import { loadFromLocalStorage, saveToLocalStorage } from "./localStorageUtils"; 21 | 22 | export { 23 | splitTextIntoLines, 24 | cleanLines, 25 | truncate, 26 | getTextStats, 27 | splitParagraph, 28 | toHalfWidth, 29 | filterLines, 30 | removeAdjacentDuplicateLines, 31 | normalizeNewlines, 32 | dedupeLines, 33 | compressNewlines, 34 | splitBySpaces, 35 | parseSpaceSeparatedItems, 36 | copyToClipboard, 37 | downloadFile, 38 | DataContext, 39 | DataProvider, 40 | preprocessJson, 41 | stripJsonWrapper, 42 | loadFromLocalStorage, 43 | saveToLocalStorage, 44 | }; 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subtitle-translator", 3 | "version": "1.2.6", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "build:lang": "node scripts/buildWithLang.js", 9 | "postbuild": "next-sitemap", 10 | "start": "next start", 11 | "lint": "next lint", 12 | "outdated": "ncu" 13 | }, 14 | "dependencies": { 15 | "@ant-design/nextjs-registry": "^1.2.0", 16 | "@next/third-parties": "14.2.33", 17 | "antd": "^5.28.1", 18 | "compromise": "^14.14.4", 19 | "deepl-node": "^1.22.0", 20 | "file-saver": "^2.0.5", 21 | "http-proxy-middleware": "^3.0.5", 22 | "jschardet": "^3.1.4", 23 | "jsonpath-plus": "^10.3.0", 24 | "next": "14.2.33", 25 | "next-intl": "^4.5.3", 26 | "next-sitemap": "^4.2.3", 27 | "next-themes": "^0.4.6", 28 | "opencc-js": "^1.0.5", 29 | "p-limit": "^7.2.0", 30 | "p-retry": "^7.1.0", 31 | "react": "^18", 32 | "react-dom": "^18", 33 | "rtl-detect": "^1.1.2", 34 | "spark-md5": "^3.0.2" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^20", 38 | "@types/react": "^18", 39 | "@types/react-dom": "^18", 40 | "@waline/client": "^3.8.0", 41 | "autoprefixer": "^10.4.21", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.2.33", 44 | "postcss": "^8", 45 | "tailwindcss": "^3.4.18", 46 | "typescript": "^5.9.2" 47 | }, 48 | "engines": { 49 | "node": ">=18.18.0" 50 | }, 51 | "packageManager": "yarn@1.22.22" 52 | } 53 | -------------------------------------------------------------------------------- /src/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@/app/globals.css"; 3 | import { Navigation } from "@/app/ui/Navigation"; 4 | import { GoogleTagManager } from "@next/third-parties/google"; 5 | import { NextIntlClientProvider, hasLocale } from "next-intl"; 6 | import { getLangDir } from "rtl-detect"; 7 | import { setRequestLocale, getTranslations, getMessages } from "next-intl/server"; 8 | import { notFound } from "next/navigation"; 9 | import { routing } from "@/i18n/routing"; 10 | import ThemesProvider from "@/app/ThemesProvider"; 11 | 12 | export async function generateMetadata({ params: { locale } }) { 13 | const t = await getTranslations({ locale, namespace: "Metadata" }); 14 | return { 15 | title: t("title"), 16 | description: t("description"), 17 | }; 18 | } 19 | 20 | export function generateStaticParams() { 21 | return routing.locales.map((locale) => ({ locale })); 22 | } 23 | 24 | export default async function LocaleLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }> }) { 25 | // Ensure that the incoming `locale` is valid 26 | const { locale } = await params; 27 | if (!hasLocale(routing.locales, locale)) { 28 | notFound(); 29 | } 30 | // Enable static rendering 31 | setRequestLocale(locale); 32 | const direction = getLangDir(locale); 33 | const messages = await getMessages(); 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 |
{children}
42 |
43 |
44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: 🐳 Build and Push Docker Image 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub and GHCR 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: 🚚 Check out the repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@v3 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: 🔑 Log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ vars.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: 🔐 Login to GitHub Container Registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GHCR_PAT }} 34 | 35 | - name: 🏷️ Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: | 40 | ${{ vars.DOCKERHUB_USERNAME }}/subtitle-translator 41 | ghcr.io/${{ github.repository_owner }}/subtitle-translator 42 | tags: | 43 | type=ref,event=tag 44 | type=raw,value=latest 45 | 46 | # Build and push Docker image to both Docker Hub and GHCR 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | -------------------------------------------------------------------------------- /src/app/components/KeyMappingInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Form, Input, Space, Tooltip } from "antd"; 3 | import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; 4 | import { useTranslations } from "next-intl"; 5 | 6 | const KeyMappingInput = ({ keyMappings = [], setKeyMappings }) => { 7 | const t = useTranslations("json"); 8 | 9 | const deleteMapping = (id) => { 10 | if (keyMappings.length > 1) { 11 | const newMappings = keyMappings.filter((mapping) => mapping.id !== id); 12 | setKeyMappings(newMappings); 13 | } 14 | }; 15 | const addMapping = () => { 16 | setKeyMappings([...keyMappings, { inputKey: "", outputKey: "", id: Date.now() }]); 17 | }; 18 | const handleInputChange = (index, field, value) => { 19 | const newMappings = [...keyMappings]; 20 | newMappings[index][field] = value; 21 | setKeyMappings(newMappings); 22 | }; 23 | 24 | return ( 25 | <> 26 | {keyMappings.map((mapping, index) => ( 27 |
28 | 29 | 30 | handleInputChange(index, "inputKey", e.target.value)} /> 31 | 32 | 33 | handleInputChange(index, "outputKey", e.target.value)} /> 34 | 35 | 36 |
40 | ))} 41 | 44 | 45 | ); 46 | }; 47 | 48 | export default KeyMappingInput; 49 | -------------------------------------------------------------------------------- /src/app/utils/jsonUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 预处理输入字符串并尝试将其解析为 JSON。 3 | * 如果输入不是有效的 JSON,尝试修复常见的格式问题并重新解析。 4 | * 如果处理失败,则抛出错误。 5 | */ 6 | 7 | // 轻量增强:移除 UTF-8 BOM 8 | const stripBOM = (s: string) => (s && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s); 9 | 10 | // 严格尝试解析:不做额外包裹,仅返回解析结果或 null 11 | const tryParse = (str: string): unknown | null => { 12 | try { 13 | return JSON.parse(str); 14 | } catch { 15 | return null; 16 | } 17 | }; 18 | 19 | export const preprocessJson = (input: string): any => { 20 | // 0) 基础清理 21 | const base = stripBOM(String(input)); 22 | let parsed = tryParse(base); 23 | if (parsed !== null) return parsed; 24 | 25 | // 1) 去除首尾空白与尾逗号(含 } 或 ] 前的尾逗号) 26 | const trimmed = base.trim(); 27 | const noTrailingCommas = trimmed.replace(/,\s*$/, "").replace(/,\s*([}\]])/g, "$1"); 28 | 29 | parsed = tryParse(noTrailingCommas); 30 | if (parsed !== null) return parsed; 31 | 32 | // 2) 在“已具备基本结构”的前提下,尽量只做键名补引号等温和修复 33 | const candidates: Array<() => string> = [ 34 | // 给未加引号的键名补引号 35 | () => noTrailingCommas.replace(/([{,]\s*)([a-zA-Z0-9_\.]+)(\s*:\s*)/g, '$1"$2"$3'), 36 | // 同样的修复,外层尝试包裹一次对象 37 | () => `{${noTrailingCommas}}`.replace(/([{,]\s*)([a-zA-Z0-9_\.]+)(\s*:\s*)/g, '$1"$2"$3'), 38 | // 同样的修复,外层尝试包裹一次数组 39 | () => `[${noTrailingCommas}]`.replace(/([{,]\s*)([a-zA-Z0-9_\.]+)(\s*:\s*)/g, '$1"$2"$3'), 40 | // 最后再兜底一次:将形如 foo: 或 "foo": 补成标准键 41 | () => noTrailingCommas.replace(/(['"])?([a-zA-Z0-9_\.]+)(['"])?:/g, '"$2":'), 42 | ]; 43 | 44 | for (const candidate of candidates) { 45 | const transformed = candidate(); 46 | parsed = tryParse(transformed); 47 | if (parsed !== null) return parsed; 48 | } 49 | 50 | throw new Error("Unable to parse JSON 无法解析 JSON 数据。"); 51 | }; 52 | 53 | /** 54 | * 去除 JSON 字符串的最外层包裹({} 或 []),返回内部内容。 55 | */ 56 | export const stripJsonWrapper = (input: string): string => { 57 | const trimmed = stripBOM(input).trim(); 58 | if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) { 59 | return trimmed.slice(1, -1).trim(); 60 | } 61 | throw new Error("JSON format error: 缺少有效的外层包裹结构,请检查格式"); 62 | }; 63 | -------------------------------------------------------------------------------- /src/app/[locale]/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useMemo, useCallback } from "react"; 4 | import { Tabs, TabsProps, Typography } from "antd"; 5 | import { VideoCameraOutlined, QuestionCircleOutlined } from "@ant-design/icons"; 6 | import TranslationSettings from "@/app/components/TranslationSettings"; 7 | import SubtitleTranslator from "./SubtitleTranslator"; 8 | import { useTranslations, useLocale } from "next-intl"; 9 | 10 | const { Title, Paragraph, Link } = Typography; 11 | 12 | const ClientPage = () => { 13 | const tSubtitle = useTranslations("subtitle"); 14 | const t = useTranslations("common"); 15 | const locale = useLocale(); 16 | const isChineseLocale = locale === "zh" || locale === "zh-hant"; 17 | 18 | const userGuideUrl = useMemo( 19 | () => (isChineseLocale ? "https://docs.newzone.top/guide/translation/subtitle-translator/index.html" : "https://docs.newzone.top/en/guide/translation/subtitle-translator/index.html"), 20 | [isChineseLocale] 21 | ); 22 | // 使用时间戳来强制重新渲染 23 | const [activeKey, setActiveKey] = useState("basic"); 24 | const [refreshKey, setRefreshKey] = useState(Date.now()); 25 | 26 | const handleTabChange = useCallback((key) => { 27 | setActiveKey(key); 28 | setRefreshKey(Date.now()); 29 | }, []); 30 | 31 | const basicTab = ; 32 | const advancedTab = ; 33 | const items: TabsProps["items"] = [ 34 | { 35 | key: "basic", 36 | label: t("basicTab"), 37 | children: basicTab, 38 | }, 39 | { 40 | key: "advanced", 41 | label: t("advancedTab"), 42 | children: advancedTab, 43 | }, 44 | ]; 45 | 46 | return ( 47 | <> 48 | 49 | <VideoCameraOutlined /> {tSubtitle("clientTitle")} 50 | 51 | 52 | 53 | {t("userGuide")} 54 | {" "} 55 | {tSubtitle("clientDescription")} {t("privacyNotice")} 56 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default ClientPage; 63 | -------------------------------------------------------------------------------- /scripts/buildWithLang.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const { execSync } = require("child_process"); 4 | 5 | // 从命令行参数中获取目标语言,默认 "en" 6 | const lang = process.argv[2] || "en"; 7 | 8 | // 定义需要修改的文件路径 9 | const routingPath = path.join(__dirname, "..", "src", "i18n", "routing.ts"); 10 | const pagePath = path.join(__dirname, "..", "src", "app", "page.tsx"); 11 | const notFoundPath = path.join(__dirname, "..", "src", "app", "not-found.tsx"); 12 | const navigationPath = path.join(__dirname, "..", "src", "app", "ui", "Navigation.tsx"); 13 | 14 | // 备份原始内容 15 | const backup = { 16 | routing: fs.readFileSync(routingPath, "utf8"), 17 | page: fs.readFileSync(pagePath, "utf8"), 18 | notFound: fs.readFileSync(notFoundPath, "utf8"), 19 | navigation: fs.readFileSync(navigationPath, "utf8"), 20 | }; 21 | 22 | let buildError = null; 23 | 24 | try { 25 | // 更新 src/i18n/routing.ts 文件 26 | let routingContent = backup.routing; 27 | routingContent = routingContent.replace(/locales:\s*\[[^\]]*\]/, `locales: ["${lang}"]`); 28 | routingContent = routingContent.replace(/defaultLocale:\s*".*?"/, `defaultLocale: "${lang}"`); 29 | fs.writeFileSync(routingPath, routingContent, "utf8"); 30 | 31 | // 辅助函数:更新文件中的 redirect 调用 32 | const updateRedirect = (filePath) => { 33 | let content = fs.readFileSync(filePath, "utf8"); 34 | content = content.replace(/redirect\(".*?"\)/g, `redirect("/${lang}")`); 35 | fs.writeFileSync(filePath, content, "utf8"); 36 | }; 37 | 38 | // 更新 src/app/page.tsx 和 src/app/not-found.tsx 文件 39 | updateRedirect(pagePath); 40 | updateRedirect(notFoundPath); 41 | 42 | // 修改 Navigation 文件:隐藏语言切换栏 43 | let navigationContent = backup.navigation; 44 | // 根据实际情况调整正则表达式以匹配语言切换栏代码块 45 | navigationContent = navigationContent.replace(//g, ""); 46 | fs.writeFileSync(navigationPath, navigationContent, "utf8"); 47 | 48 | console.log(`Temp update done (临时更新完成): using language "${lang}" and hide language switch bar (使用语言 "${lang}" 并隐藏语言切换栏)`); 49 | 50 | // 执行构建命令(调用 Next.js 的构建命令) 51 | execSync("next build", { stdio: "inherit" }); 52 | } catch (error) { 53 | console.error("Build error occurred / 构建错误发生:", error); 54 | buildError = error; // 记录错误但不立即退出 55 | } finally { 56 | // 构建完成后无论如何还原所有文件 57 | fs.writeFileSync(routingPath, backup.routing, "utf8"); 58 | fs.writeFileSync(pagePath, backup.page, "utf8"); 59 | fs.writeFileSync(notFoundPath, backup.notFound, "utf8"); 60 | fs.writeFileSync(navigationPath, backup.navigation, "utf8"); 61 | console.log("Files restored (文件已还原到原始状态)"); 62 | 63 | // 如果之前有错误,则退出进程 64 | if (buildError) { 65 | process.exit(1); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/api/deepl/route.ts: -------------------------------------------------------------------------------- 1 | // 文件:/app/api/deepl/route.ts 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import * as deepl from "deepl-node"; 4 | 5 | const TARGET_LANG_MAPPING = { 6 | en: "en-US", // 默认英语使用美式英语 7 | pt: "pt-BR", // 默认葡萄牙语使用巴西葡萄牙语 8 | }; 9 | 10 | export async function POST(req: NextRequest) { 11 | try { 12 | // 解析请求体 13 | const requestBody = await req.json(); 14 | let { text, target_lang, source_lang, authKey } = requestBody; 15 | 16 | // 验证请求参数 17 | if (!text || !target_lang) { 18 | return NextResponse.json({ error: "Missing required parameters: text and target_lang are required" }, { status: 400 }); 19 | } 20 | 21 | // 验证 API 密钥 22 | if (!authKey) { 23 | return NextResponse.json({ error: "Missing required parameter: authKey is required" }, { status: 400 }); 24 | } 25 | 26 | // 目标语言:处理弃用的语言代码 27 | if (TARGET_LANG_MAPPING[target_lang]) { 28 | console.log(`Converting deprecated language code '${target_lang}' to '${TARGET_LANG_MAPPING[target_lang]}'`); 29 | target_lang = TARGET_LANG_MAPPING[target_lang]; 30 | } 31 | 32 | // 初始化 DeepL 翻译器 33 | const translator = new deepl.Translator(authKey); 34 | 35 | // 调用 DeepL API 进行翻译 36 | const result = await translator.translateText( 37 | text, 38 | source_lang || null, // 如果未提供源语言,则为自动检测 39 | target_lang 40 | ); 41 | 42 | // 返回翻译结果 43 | return NextResponse.json({ 44 | translations: Array.isArray(result) 45 | ? result.map((item) => ({ 46 | detected_source_language: item.detectedSourceLang, 47 | text: item.text, 48 | })) 49 | : [ 50 | { 51 | detected_source_language: result.detectedSourceLang, 52 | text: result.text, 53 | }, 54 | ], 55 | }); 56 | } catch (error) { 57 | console.error("DeepL translation error:", error); 58 | 59 | // 如果是语言代码问题,提供更明确的错误信息 60 | if (error instanceof deepl.DeepLError && (error.message.includes("is deprecated") || error.message.includes("not supported"))) { 61 | // 提取错误消息 62 | const errorMsg = error.message; 63 | 64 | // 返回详细的错误信息和建议 65 | return NextResponse.json( 66 | { 67 | error: `DeepL API error: ${errorMsg}`, 68 | suggestion: "请更新您的语言代码。例如,使用'en-US'或'en-GB'代替'en',使用'pt-BR'或'pt-PT'代替'pt'。", 69 | }, 70 | { status: 400 } 71 | ); 72 | } 73 | 74 | // 处理可能的其他 API 错误 75 | if (error instanceof deepl.DeepLError) { 76 | return NextResponse.json({ error: `DeepL API error: ${error.message}` }, { status: 500 }); 77 | } 78 | 79 | // 处理其他错误 80 | return NextResponse.json({ error: error.message || "An unknown error occurred" }, { status: 500 }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/utils/jsonPathUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JSONPath 工具函数 3 | * 用于处理 JSONPath 查询结果的过滤和处理 4 | */ 5 | 6 | /** 7 | * 过滤 JSONPath 结果,只保留真正的对象属性匹配,排除数组索引匹配 8 | * 这个函数主要解决数字键名(如 "1", "2" 等)同时匹配对象属性和数组索引的问题 9 | * 10 | * @param {Array} results - JSONPath 查询返回的结果数组 11 | * @param {string} keyName - 要查询的键名 12 | * @returns {Array} 过滤后的结果数组,只包含真正的对象属性匹配 13 | * 14 | * @example 15 | * // 这个函数解决数字键匹配数组索引的问题 16 | * // 保留对象属性匹配,过滤掉数组索引匹配 17 | */ 18 | export const filterObjectPropertyMatches = (results, keyName) => { 19 | return results.filter(result => { 20 | const pathStr = result.path; 21 | 22 | // 如果键名是纯数字,我们需要特别小心 23 | const isNumericKey = /^\d+$/.test(keyName); 24 | if (isNumericKey) { 25 | // 对于数字键,需要区分对象属性访问和数组索引访问 26 | 27 | // 查找所有包含这个数字的路径段 28 | const segments = pathStr.match(/\[[^\]]+\]/g) || []; 29 | 30 | for (let i = 0; i < segments.length; i++) { 31 | const segment = segments[i]; 32 | 33 | // 检查当前段是否匹配我们的键 34 | if (segment === `[${keyName}]` || segment === `['${keyName}']` || segment === `["${keyName}"]`) { 35 | // 找到匹配的段,现在检查它是否是数组索引访问 36 | 37 | // 如果这是路径中的最后一个段,并且前面的段表示数组 38 | // 我们需要检查前一个段的上下文 39 | if (i > 0) { 40 | const prevSegment = segments[i - 1]; 41 | 42 | // 如果前一个段是数字(数组索引),或者是字符串键名 43 | // 我们需要检查这个上下文来判断当前段是否是数组索引 44 | 45 | // 简单启发式:如果这个数字段紧跟在一个字符串键后面 46 | // 而且这个字符串键很可能是数组(比如包含 'content', 'items', 'list' 等) 47 | const prevIsStringKey = /\['[^']*'\]/.test(prevSegment); 48 | const prevKeyName = prevSegment.match(/\['([^']*)'\]/)?.[1]; 49 | 50 | if (prevIsStringKey && prevKeyName) { 51 | // 检查前面的键名是否暗示这是一个数组 52 | const arrayLikeKeys = ['content', 'items', 'list', 'array', 'data']; 53 | const isArrayLikeKey = arrayLikeKeys.some(arrayKey => 54 | prevKeyName.toLowerCase().includes(arrayKey) 55 | ); 56 | 57 | if (isArrayLikeKey) { 58 | // 这很可能是数组索引访问,过滤掉 59 | return false; 60 | } 61 | } 62 | } else { 63 | // 这是根级别的访问,应该是对象属性 64 | return true; 65 | } 66 | } 67 | } 68 | 69 | // 如果没有找到匹配或者通过了所有检查,保留 70 | return true; 71 | } else { 72 | // 对于非数字键,所有匹配都应该是有效的对象属性 73 | return true; 74 | } 75 | }); 76 | }; 77 | 78 | /** 79 | * 安全的 JSONPath 查询函数,自动过滤数字键的数组索引匹配 80 | * 81 | * @param {Object} options - JSONPath 查询选项 82 | * @param {string} options.path - JSONPath 表达式 83 | * @param {Object} options.json - 要查询的 JSON 对象 84 | * @param {string} options.resultType - 结果类型,默认 "all" 85 | * @param {string} keyName - 查询的键名,用于过滤 86 | * @returns {Array} 过滤后的查询结果 87 | */ 88 | export const safeJSONPathQuery = ({ path, json, resultType = "all" }, keyName) => { 89 | const { JSONPath } = require('jsonpath-plus'); 90 | const results = JSONPath({ path, json, resultType }); 91 | 92 | // 如果路径是 $..keyName 格式,应用过滤 93 | if (path.startsWith('$..') && keyName) { 94 | return filterObjectPropertyMatches(results, keyName); 95 | } 96 | 97 | return results; 98 | }; 99 | -------------------------------------------------------------------------------- /src/app/hooks/useFileUpload.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { normalizeNewlines } from "@/app/utils"; 4 | import type { UploadFile, UploadProps } from "antd"; 5 | import jschardet from "jschardet"; 6 | 7 | const useFileUpload = () => { 8 | const [sourceText, setSourceText] = useState(""); 9 | const [multipleFiles, setMultipleFiles] = useState([]); 10 | const [uploadMode, setUploadMode] = useState<"single" | "multiple">("single"); 11 | const [fileList, setFileList] = useState([]); 12 | const [singleFileMode, setSingleFileMode] = useState(false); 13 | const [isFileProcessing, setIsFileProcessing] = useState(false); 14 | 15 | const readFile = (file: File, callback: (text: string) => void) => { 16 | setIsFileProcessing(true); 17 | const reader = new FileReader(); 18 | 19 | reader.onload = (e) => { 20 | const buffer = e.target?.result as ArrayBuffer; 21 | const uint8Array = new Uint8Array(buffer); 22 | 23 | // 大文件性能优化:仅抽样前 512KB 用于编码检测,避免将整文件转为字符串 24 | const SAMPLE_SIZE = 512 * 1024; 25 | const sample = uint8Array.subarray(0, Math.min(SAMPLE_SIZE, uint8Array.length)); 26 | const sampleString = Array.from(sample) 27 | .map((byte) => String.fromCharCode(byte)) 28 | .join(""); 29 | 30 | // 检测编码(基于样本),后续仍对完整内容进行解码 31 | const detected = jschardet.detect(sampleString); 32 | console.log("Detected encoding", detected); 33 | 34 | // 解码文件内容 35 | const decoder = new TextDecoder(detected.encoding || "utf-8"); 36 | const text = decoder.decode(uint8Array); 37 | const normalized = normalizeNewlines(text); 38 | callback(normalized); 39 | setIsFileProcessing(false); 40 | }; 41 | 42 | reader.onerror = (error) => { 43 | console.error("读取文件出错:", error); 44 | setIsFileProcessing(false); 45 | }; 46 | reader.readAsArrayBuffer(file); 47 | }; 48 | 49 | const handleUploadChange: UploadProps["onChange"] = ({ fileList }) => { 50 | const updatedFileList: UploadFile[] = fileList.map((f) => ({ 51 | uid: f.uid, 52 | name: f.name, 53 | status: "done", 54 | size: f.size, 55 | originFileObj: f.originFileObj, 56 | })); 57 | 58 | // Deduplicate files based on name and size 59 | const uniqueFileList = updatedFileList.filter((value, index, self) => index === self.findIndex((t) => t.name === value.name && t.size === value.size)); 60 | setFileList(uniqueFileList); 61 | 62 | if (uniqueFileList.length > 1 && uploadMode === "single") { 63 | setSourceText(""); 64 | setUploadMode("multiple"); 65 | } else if (uniqueFileList.length === 0) { 66 | resetUpload(); 67 | } 68 | }; 69 | 70 | const handleFileUpload = (uploadedFile: File) => { 71 | if (uploadMode === "single") { 72 | setSourceText(""); 73 | setMultipleFiles([uploadedFile]); 74 | readFile(uploadedFile, (text) => { 75 | setSourceText(text); 76 | }); 77 | } else { 78 | setMultipleFiles((prevFiles) => { 79 | // Prevent adding duplicate files 80 | const isFileAlreadyAdded = prevFiles.some((existingFile) => existingFile.name === uploadedFile.name && existingFile.size === uploadedFile.size); 81 | 82 | // 如果文件未添加,则添加 83 | if (!isFileAlreadyAdded) { 84 | const newFiles = [...prevFiles, uploadedFile]; 85 | console.log("New multiple files", newFiles); 86 | return newFiles; 87 | } 88 | 89 | return prevFiles; 90 | }); 91 | } 92 | 93 | // Return false to prevent default upload behavior 94 | return false; 95 | }; 96 | 97 | const handleUploadRemove: UploadProps["onRemove"] = (file: UploadFile) => { 98 | // 从 fileList 中移除 99 | const updatedFileList = fileList.filter((f) => f.uid !== file.uid); 100 | setFileList(updatedFileList); 101 | 102 | // 从 multipleFiles 中移除 103 | setMultipleFiles((prevFiles) => { 104 | // 使用文件名和大小作为唯一标识 105 | const updatedMultipleFiles = prevFiles.filter((f) => !(f.name === file.name && f.size === file.size)); 106 | 107 | // 如果只剩下一个文件,则切换到单文件模式,且读取文件内容 108 | if (updatedMultipleFiles.length === 1 && uploadMode === "multiple") { 109 | setUploadMode("single"); 110 | readFile(updatedMultipleFiles[0], (text) => { 111 | setSourceText(text); 112 | }); 113 | } 114 | 115 | return updatedMultipleFiles; 116 | }); 117 | }; 118 | 119 | const resetUpload = () => { 120 | //setFile(null); 121 | setFileList([]); 122 | setMultipleFiles([]); 123 | setSourceText(""); 124 | setUploadMode("single"); 125 | }; 126 | 127 | return { 128 | isFileProcessing, 129 | fileList, 130 | multipleFiles, 131 | readFile, 132 | sourceText, 133 | setSourceText, 134 | uploadMode, 135 | singleFileMode, 136 | setSingleFileMode, 137 | handleFileUpload, 138 | handleUploadRemove, 139 | handleUploadChange, 140 | resetUpload, 141 | }; 142 | }; 143 | 144 | export default useFileUpload; 145 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 |

2 | ⚡️Subtitle Translator 3 |

4 |

5 | English | 中文 6 |

7 |

8 | Translate subtitles effortlessly—fast, accurate, and multilingual! 9 |

10 | 11 | **Subtitle Translator** 是一款**免费、开源**的批量字幕翻译工具,支持 `.srt`、`.ass`、`.vtt` 等字幕格式,并具备**秒级翻译**能力。通过**多种翻译接口(API + AI 大模型)**,可将字幕文件**快速翻译成 50 种语言**,并支持**多语言同时翻译**,满足国际化需求。 12 | 13 | 相较于传统字幕翻译工具,Subtitle Translator 具备**批量翻译、高速处理、翻译缓存、自动格式匹配**等优势,能大幅提升字幕翻译效率,适用于影视、教育、内容创作等多个场景。 14 | 15 | 👉 **在线体验**: 16 | 17 | ## 特色功能 18 | 19 | !["批量翻译"](https://img.newzone.top/subtile-translator.gif?imageMogr2/format/webp "批量翻译") 20 | 21 | - **秒级翻译**:利用字幕文本的分块压缩和并行处理技术,实现 1 秒翻译一集电视剧(GTX 接口稍慢)。 22 | - **批量翻译**:支持一次性处理上百份字幕文件,极大提升翻译效率。 23 | - **翻译缓存**:自动本地缓存翻译结果,避免重复调用,节省时间和 API 费用。 24 | - **格式兼容**:自动匹配主流字幕格式(.srt / .ass / .vtt),导出文件与原文件名一致,无需手动调整。 25 | - **字幕提取**:支持提取字幕文本,方便后续用于 AI 总结、二次创作等应用场景。 26 | - **多接口选择**:提供 3 种免费翻译方式、3 种商业级翻译 API,以及 5 种 AI LLM(大模型)接口,满足不同需求。 27 | - **多语言支持与国际化**:支持 50 种主流语言(英语、中文、日语、韩语、法语、德语、西班牙语等),还可将同一字幕文件同时翻译成多种语言,满足国际化需求。 28 | 29 | Subtitle Translator 提供了丰富的参数选项,以适应不同用户的需求。以下是各项参数的详细说明: 30 | 31 | ## 翻译 API 32 | 33 | Subtitle Translator 支持 5 种翻译 API 和 5 种 LLM(大语言模型)接口,用户可根据需求选择合适的翻译方式: 34 | 35 | ### 翻译 API 对比 36 | 37 | | API 类型 | 翻译质量 | 稳定性 | 适用场景 | 免费额度 | 38 | | -------------------- | -------- | ------ | ---------------------- | ------------------------------ | 39 | | **DeepL(X)** | ★★★★★ | ★★★★☆ | 适合长文本,翻译更流畅 | 每月 50 万字符 | 40 | | **Google Translate** | ★★★★☆ | ★★★★★ | 适合 UI 界面、常见句子 | 每月 50 万字符 | 41 | | **Azure Translate** | ★★★★☆ | ★★★★★ | 语言支持最广泛 | **前 12 个月** 每月 200 万字符 | 42 | | **GTX API(免费)** | ★★★☆☆ | ★★★☆☆ | 一般文本翻译 | 免费 | 43 | | **GTX Web(免费)** | ★★★☆☆ | ★★☆☆☆ | 适合小规模翻译 | 免费 | 44 | 45 | - **DeepL**:适用于长篇文本,翻译更加流畅自然,但不支持网页端 API,需本地或服务器代理调用。 46 | - **Google Translate**:翻译质量稳定,适用于短句、界面文本,支持网页端调用。 47 | - **Azure Translate**:支持语言最多,适合多语言翻译需求。 48 | - **GTX API/Web**:免费翻译选项,适合小规模使用,但稳定性一般。 49 | 50 | 如果对翻译速度和质量有更高要求,可自行申请 API Key:[Google Translate](https://cloud.google.com/translate/docs/setup?hl=zh-cn)、[Azure Translate](https://learn.microsoft.com/zh-cn/azure/ai-services/translator/reference/v3-0-translate)、[DeepL Translate](https://www.deepl.com/your-account/keys)。申请流程参考相关的[接口申请教程](https://ttime.timerecord.cn/service/translate/google.html)。更多支持语言详见: 51 | 52 | - [DeepL 支持语言](https://developers.deepl.com/docs/v/zh/api-reference/languages) 53 | - [Google Translate 支持语言](https://cloud.google.com/translate/docs/languages?hl=zh-cn) 54 | - [Azure 支持语言](https://learn.microsoft.com/zh-cn/azure/ai-services/translator/language-support) 55 | 56 | ### LLM 翻译(AI 大模型) 57 | 58 | Subtitle Translator 还支持 5 种 AI LLM 模型进行翻译,包括 OpenAI、DeepSeek、Siliconflow、Groq 等。 59 | 60 | - **适用场景**:适合更复杂的语言理解需求,如文学作品、技术文档等。 61 | - **可定制性**:支持自定义系统提示词(System Prompt)和用户提示词(User Prompt),让翻译风格更加符合预期。 62 | - **温度控制(temperature)**:可以调整 AI 翻译的随机性,数值越高,翻译越有创意,但可能会降低稳定性。 63 | 64 | ## 字幕格式 65 | 66 | Subtitle Translator 支持 `.srt`、`.ass`、`.vtt` 等多种字幕格式,并提供自动格式匹配功能: 67 | 68 | - **双语字幕**:勾选后,翻译后的文本将插入原字幕下方,并可调整译文的显示位置(上/下)。 69 | - **时间轴兼容性**:支持省略默认小时、超过 100 小时的时间格式,以及 1~3 位毫秒显示,确保兼容性。 70 | - **自动编码识别**:无需手动选择编码格式,工具会自动识别字幕文件编码,避免乱码问题。 71 | 72 | ## 翻译模式 73 | 74 | Subtitle Translator 支持批量翻译和单文件模式,适应不同使用需求: 75 | 76 | **批量翻译**(默认): 77 | 78 | - 支持同时处理上百个文件,大幅提升工作效率。 79 | - 翻译后的文件将自动保存在浏览器默认下载目录,无需手动操作。 80 | 81 | **单文件模式**(适用于小型任务): 82 | 83 | - 适用于单个字幕的快速翻译,支持直接粘贴文本翻译。 84 | - 翻译结果可在网页端实时查看,并手动复制或导出。 85 | - 若开启单文件模式,则**上传新文件会覆盖上一个文件**。 86 | 87 | ## 翻译缓存 88 | 89 | Subtitle Translator 采用可选的本地缓存机制,提高翻译效率: 90 | 91 | - **缓存规则**:每段翻译结果将以 `源文本_目标语言_源语言_翻译 API_模型设置` 作为唯一 key 进行存储。 92 | - **缓存命中条件**:只有完全匹配相同参数的情况下,才会使用本地缓存结果,确保准确性。 93 | - **缓存作用**:避免重复翻译,减少 API 调用次数,提高翻译速度。 94 | 95 | ## 多语言翻译 96 | 97 | Subtitle Translator 允许 **将同一个字幕文件翻译成多种语言**,适用于国际化场景: 98 | 99 | - 例如:将英文字幕同时翻译为中文、日语、德语、法语,方便全球用户使用。 100 | - 支持 50 种主流语言,并将持续扩展。 101 | 102 | ## 使用注意 103 | 104 | 使用 Subtitle Translator 时,请注意以下几点: 105 | 106 | - DeepL API 不支持在网页上使用,所以 Subtitle Translator 在服务器端提供了一个专门的 DeepL 翻译转发接口,该接口仅用于数据转发,不会收集任何用户数据。用户可以选择在本地环境中部署并使用这一接口。 107 | - Subtitle Translator 不会储存你的 API Key,所有数据均缓存在本地浏览器中。 108 | - GTX Web 接口对服务器压力过大,改为仅在本地运行。另外,避免在全局代理环境下使用 GTX Web 接口,以免出现翻译错误。 109 | 110 | ## 项目部署 111 | 112 | Subtitle Translator 可部署到 CloudFlare、Vercel、EdgeOne 或任意服务器。 113 | 114 | [![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Frockbenben%2Fsubtitle-translator&output-directory=out&install-command=yarn+install&build-command=yarn+build%3Alang+zh) 115 | 116 | System Requirements: 117 | 118 | - [Node.js 18.18](https://nodejs.org/) or later. 119 | - macOS, Windows (including WSL), and Linux are supported. 120 | 121 | ```shell 122 | # Installation(安装依赖) 123 | yarn 124 | 125 | # Local Development (本地开发) 126 | yarn dev 127 | 128 | # build and start (构建并启动) 129 | yarn build && npx serve@latest out 130 | 131 | # Deploy for a single language(单一语言部署) 132 | yarn build:lang en 133 | yarn build:lang zh 134 | yarn build:lang zh-hant 135 | ``` 136 | 137 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 138 | 139 | You can start editing the page by modifying `src/app/[locale]/page.tsx`. The page auto-updates as you edit the file. 140 | -------------------------------------------------------------------------------- /src/app/utils/textUtils.ts: -------------------------------------------------------------------------------- 1 | // 统一换行符为 \n(将 Windows 的 \r\n 和旧 Mac 的 \r 规范为 \n),对已为 \n 的内容不做多余替换 2 | export const normalizeNewlines = (text: string): string => (text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text); 3 | 4 | interface SplitOptions { 5 | removeEmptyLines?: boolean; // 如果为 true, 将移除结果数组中的所有严格空字符串行 ("") 6 | } 7 | export const splitTextIntoLines = (text: string, options: SplitOptions = {}): string[] => { 8 | if (!text) { 9 | return []; 10 | } 11 | // 先统一换行符,避免在此处重复书写换行正则 12 | const normalized = normalizeNewlines(text); 13 | let lines = normalized.split("\n"); 14 | // 现在这里的 options 永远不会是 undefined,代码是安全的 15 | if (options.removeEmptyLines) { 16 | lines = lines.filter(Boolean); 17 | } 18 | return lines; 19 | }; 20 | 21 | // 过滤掉只包含空白的行,并根据 shouldTrim 参数决定是否去掉每行的首尾空白 22 | export const cleanLines = (text: string, shouldTrim: boolean = false): string[] => 23 | splitTextIntoLines(text) 24 | .filter((line) => line.trim()) 25 | .map((line) => (shouldTrim ? line.trim() : line)); 26 | 27 | // 截断字符串到指定长度,默认长度为 100K 28 | const MAX_LENGTH = 100000; 29 | export const truncate = (str: string, num: number = MAX_LENGTH): string => (str.length <= num ? str : `${str.slice(0, num)}...`); 30 | 31 | const compactFormatter = new Intl.NumberFormat("en-US", { 32 | notation: "compact", 33 | compactDisplay: "short", 34 | }); 35 | 36 | const MAX_CHAR_LENGTH = 1000000; 37 | export const getTextStats = (str: string, num: number = MAX_CHAR_LENGTH) => { 38 | const totalChars = str.length; 39 | const totalLines = splitTextIntoLines(str).length; 40 | const isTooLong = totalChars > num; 41 | const displayText = isTooLong ? truncate(str) : str; 42 | 43 | return { 44 | charCount: compactFormatter.format(totalChars), 45 | lineCount: compactFormatter.format(totalLines), 46 | isTooLong, 47 | displayText, 48 | }; 49 | }; 50 | 51 | // 中文段落分割处理 52 | const splitCNParagraph = (text: string) => { 53 | const paragraphCNSplitRegex = 54 | /(如下:(?!\\n)|[^\\n“”][。;!?]”?\\b(?=[\\w・&&[^\\d]]{2,11}:[^\\n“])|(?:\\w」?;)(?=[^\\n“”:;]{14})|(?<=\\w:“[^\\n“”]{1,39}[。!?—…]”)(?=[^\\n“”:;]{1,39}:“)|(?:[^\\n【】]】)(?=【\\w{1,7}:)|(?:\\w[:;。!?]{1,2}[”]?)(?=[第其][一二三四五六七八九][,、]|[一二三四五六七八九][则来是者]?[,、]|[①-⓿][^\\n]|([^\\n()]{17,29}[。!?…])\\n)|(?:[^\\n“”][。!?—…]”)(?:[\\u4e00-\\u9fa5]{1,14}[说道]。)?(?=“[^\\n“”])|(?<=[^\\n]{4})(?:[^\\n]{24}[。!?—…][』”’】])]?)(?=[^\\n]{29})(? => { 60 | const nlp = (await import("compromise")).default; 61 | return nlp(text).sentences().out("array").join("\n"); 62 | }; 63 | 64 | type ParagraphSplitMethod = "cn" | "en"; 65 | export const splitParagraph = async (text: string, method: ParagraphSplitMethod = "cn"): Promise => { 66 | switch (method) { 67 | case "cn": 68 | return splitCNParagraph(text); 69 | case "en": 70 | return await splitEnglishParagraph(text); 71 | } 72 | }; 73 | 74 | // 将字符串中的全角数字和字母转为半角 75 | export const toHalfWidth = (text: string): string => text.replace(/[0-9A-Za-z]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 65248)); 76 | 77 | // 过滤文本中的行;filters 可为逗号分隔字符串或字符串数组 78 | export const filterLines = (text: string, filters: string | string[], maxLen?: number): string => { 79 | const list = Array.isArray(filters) 80 | ? filters 81 | : filters 82 | .split(",") 83 | .map((w) => w.trim()) 84 | .filter(Boolean); 85 | return splitTextIntoLines(text) 86 | .filter((line) => { 87 | if (maxLen !== undefined && line.trim().length > maxLen) return true; 88 | return !list.some((f) => f && line.includes(f)); 89 | }) 90 | .join("\n"); 91 | }; 92 | 93 | // 移除相邻重复行(比较时会 trim) 94 | export const removeAdjacentDuplicateLines = (lines: string[]): string[] => { 95 | if (lines.length === 0) return lines; 96 | const out: string[] = []; 97 | for (let i = 0; i < lines.length; i++) { 98 | const cur = lines[i].trim(); 99 | const prev = i > 0 ? lines[i - 1].trim() : ""; 100 | if (i === 0 || cur !== prev) out.push(lines[i]); 101 | } 102 | return out; 103 | }; 104 | 105 | // 通用:移除所有重复行(非相邻去重),支持 trim 比较与排除集合 106 | export interface DedupeOptions { 107 | trim?: boolean; 108 | exclude?: Iterable; 109 | } 110 | export const dedupeLines = (lines: string[], options: DedupeOptions = {}): string[] => { 111 | const { trim = true, exclude } = options; 112 | const excludeSet = exclude ? new Set(exclude) : undefined; 113 | const seen = new Set(); 114 | const out: string[] = []; 115 | for (const line of lines) { 116 | const key = trim ? line.trim() : line; 117 | if (excludeSet && excludeSet.has(key)) continue; 118 | if (!seen.has(key)) { 119 | seen.add(key); 120 | out.push(line); 121 | } 122 | } 123 | return out; 124 | }; 125 | 126 | // 压缩连续换行符,默认将 3 个及以上换行压缩为 2 个 127 | export const compressNewlines = (text: string, maxConsecutive: number = 2): string => { 128 | if (maxConsecutive < 1) return text.replace(/\n+/g, "\n"); 129 | const re = new RegExp(`\\n{${maxConsecutive + 1},}`, "g"); 130 | return text.replace(re, "\n".repeat(maxConsecutive)); 131 | }; 132 | 133 | //将空格分隔的字符串解析为数组(不处理转义字符) 134 | export const splitBySpaces = (input: string): string[] => { 135 | if (!input || !input.trim()) return []; 136 | return input 137 | .trim() 138 | .split(/\s+/) 139 | .filter((item) => item.length > 0); 140 | }; 141 | 142 | /** 143 | * 解析用户输入的转义字符,将字符串中的转义序列转换为实际字符 144 | * 支持的转义字符: \n(换行), \r(回车), \t(制表符), \s(空格), \\(反斜杠) 145 | */ 146 | const parseEscapeChars = (str: string): string => { 147 | return str 148 | .replace(/\\n/g, "\n") // 换行 149 | .replace(/\\r/g, "\r") // 回车 150 | .replace(/\\t/g, "\t") // 制表符 151 | .replace(/\\s/g, " ") // 空格 152 | .replace(/\\\\/g, "\\"); // 反斜杠(必须放在最后) 153 | }; 154 | 155 | // 将空格分隔的字符串解析为数组,并处理转义字符 156 | export const parseSpaceSeparatedItems = (input: string): string[] => { 157 | const items = splitBySpaces(input); 158 | // 处理每个项的转义字符 159 | return items.map((item) => parseEscapeChars(item)); 160 | }; 161 | -------------------------------------------------------------------------------- /src/app/[locale]/subtitleUtils.ts: -------------------------------------------------------------------------------- 1 | // 用于匹配 VTT/SRT 时间行(支持默认小时省略、多位数小时以及 1 到 3 位毫秒值) 2 | export const VTT_SRT_TIME = /^(?:\d+:)?\d{2}:\d{2}[,.]\d{1,3} --> (?:\d+:)?\d{2}:\d{2}[,.]\d{1,3}$/; 3 | // LRC 格式的时间标记正则表达式 4 | export const LRC_TIME_REGEX = /^\[\d{2}:\d{2}(\.\d{2,3})?\]/; 5 | const LRC_METADATA_REGEX = /^\[(ar|ti|al|by|offset|re|ve):/i; 6 | 7 | // 识别字幕文件的类型 8 | export const detectSubtitleFormat = (lines: string[]): "ass" | "vtt" | "srt" | "lrc" | "error" => { 9 | // 获取前 50 行,并去除其中的空行 10 | const nonEmptyLines = lines.slice(0, 50).filter((line) => line.trim().length > 0); 11 | let assCount = 0, 12 | vttCount = 0, 13 | srtCount = 0, 14 | lrcCount = 0; 15 | 16 | for (let i = 0; i < nonEmptyLines.length; i++) { 17 | const trimmed = nonEmptyLines[i].trim(); 18 | 19 | // ASS 格式判断:如果存在 [script info],或对话行符合 ASS 格式 20 | if (/^\[script info\]/i.test(trimmed)) return "ass"; 21 | 22 | // 如果第一行是 WEBVTT 标识,则为 VTT 格式 23 | if (i === 0 && /^WEBVTT($|\s)/i.test(trimmed)) return "vtt"; 24 | 25 | if (/^dialogue:\s*\d+,[^,]*,[^,]*,/i.test(trimmed)) { 26 | assCount++; 27 | } 28 | // 匹配时间行 29 | if (VTT_SRT_TIME.test(trimmed)) { 30 | if (trimmed.includes(",")) { 31 | srtCount++; 32 | } else if (trimmed.includes(".")) { 33 | vttCount++; 34 | } 35 | } 36 | // 检测LRC格式的时间标记 37 | if (LRC_TIME_REGEX.test(trimmed)) { 38 | lrcCount++; 39 | } 40 | if (LRC_METADATA_REGEX.test(trimmed)) { 41 | lrcCount++; 42 | } 43 | } 44 | 45 | // 根据时间行分隔符数量判断 46 | if (assCount > 0 && assCount >= Math.max(vttCount, srtCount, lrcCount)) { 47 | return "ass"; 48 | } 49 | if (lrcCount > 0 && lrcCount >= Math.max(vttCount, srtCount)) { 50 | return "lrc"; 51 | } 52 | if (vttCount > srtCount) return "vtt"; 53 | if (srtCount > 0) return "srt"; 54 | return "error"; 55 | }; 56 | 57 | export const getOutputFileExtension = (fileType: string, bilingualSubtitle: boolean): string => { 58 | if (fileType === "lrc") { 59 | return "lrc"; 60 | } else if (bilingualSubtitle || fileType === "ass") { 61 | return "ass"; 62 | } else if (fileType === "vtt") { 63 | return "vtt"; 64 | } else { 65 | return "srt"; 66 | } 67 | }; 68 | 69 | // 预编译正则表达式用于检测纯数字行 70 | const INTEGER_REGEX = /^\d+$/; 71 | // 检测当前行是否为整数和空行 72 | const isValidSubtitleLine = (str: string): boolean => { 73 | const trimmedStr = str.trim(); 74 | return trimmedStr !== "" && !INTEGER_REGEX.test(trimmedStr); 75 | }; 76 | 77 | export const filterSubLines = (lines: string[], fileType: string) => { 78 | const contentLines: string[] = []; 79 | const contentIndices: number[] = []; 80 | const styleBlockLines: string[] = []; 81 | let startExtracting = false; 82 | let assContentStartIndex = 9; 83 | let formatFound = false; 84 | 85 | if (fileType === "ass") { 86 | const eventIndex = lines.findIndex((line) => line.trim() === "[Events]"); 87 | if (eventIndex !== -1) { 88 | for (let i = eventIndex; i < lines.length; i++) { 89 | if (lines[i].startsWith("Format:")) { 90 | const formatLine = lines[i]; 91 | assContentStartIndex = formatLine.split(",").length - 1; 92 | formatFound = true; 93 | break; 94 | } 95 | } 96 | } 97 | 98 | if (!formatFound) { 99 | const dialogueLines = lines.filter((line) => line.startsWith("Dialogue:")).slice(0, 100); 100 | if (dialogueLines.length > 0) { 101 | const commaCounts = dialogueLines.map((line) => line.split(",").length - 1); 102 | assContentStartIndex = Math.min(...commaCounts); 103 | } 104 | } 105 | } 106 | 107 | lines.forEach((line, index) => { 108 | let isContent = false; 109 | let extractedContent = ""; 110 | const trimmedLine = line.trim(); 111 | 112 | if (fileType === "srt" || fileType === "vtt") { 113 | if (!startExtracting) { 114 | const isTimecode = /^[\d:,]+ --> [\d:,]+$/.test(line) || /^[\d:.]+ --> [\d:.]+$/.test(line); 115 | if (isTimecode) { 116 | startExtracting = true; 117 | } 118 | } 119 | 120 | if (startExtracting) { 121 | if (fileType === "vtt") { 122 | const isTimecode = /^[\d:.]+ --> [\d:.]+$/.test(trimmedLine); 123 | const isWebVTTHeader = trimmedLine.startsWith("WEBVTT"); 124 | const isComment = trimmedLine.startsWith("#"); 125 | isContent = isValidSubtitleLine(line) && !isTimecode && !isWebVTTHeader && !isComment; 126 | extractedContent = line; 127 | } else { 128 | const isTimecode = /^[\d:,]+ --> [\d:,]+$/.test(trimmedLine); 129 | isContent = isValidSubtitleLine(line) && !isTimecode; 130 | extractedContent = line; 131 | } 132 | } 133 | } else if (fileType === "lrc") { 134 | if (!startExtracting && LRC_TIME_REGEX.test(trimmedLine)) { 135 | startExtracting = true; 136 | } 137 | 138 | if (startExtracting) { 139 | extractedContent = trimmedLine.replace(/\[\d{2}:\d{2}(\.\d{2,3})?\]/g, "").trim(); 140 | // 只有当去除时间标记后内容不为空时,才认为是有效内容 141 | isContent = isValidSubtitleLine(line); 142 | } 143 | } else if (fileType === "ass") { 144 | if (!startExtracting && trimmedLine.startsWith("Dialogue:")) { 145 | startExtracting = true; 146 | } 147 | 148 | if (startExtracting) { 149 | const parts = line.split(","); 150 | if (line.startsWith("Dialogue:") && parts.length > assContentStartIndex) { 151 | extractedContent = parts.slice(assContentStartIndex).join(",").trim(); 152 | isContent = isValidSubtitleLine(line); 153 | } 154 | } 155 | } 156 | 157 | if (isContent) { 158 | contentLines.push(extractedContent); 159 | contentIndices.push(index); 160 | } 161 | }); 162 | 163 | return { contentLines, contentIndices, styleBlockLines }; 164 | }; 165 | 166 | // 将 WebVTT 或 SRT 的时间格式 "00:01:32.783" 或 "00:01:32,783" 转换为 ASS 的时间格式 "0:01:32.78" 167 | // 同时处理有小时和无小时的情况 168 | const TIME_REGEX = /^(?:(\d+):)?(\d{2}):(\d{2})[,.](\d{1,3})$/; 169 | export const convertTimeToAss = (time: string): string => { 170 | const match = time.match(TIME_REGEX); 171 | if (!match) return time; 172 | const [_, hours, minutes, seconds, ms] = match; 173 | // 处理毫秒:确保转换为两位厘秒。如果输入是毫秒(3 位数),取前两位;如果只有一位数如 9,用 0 填充,显示为 09。 174 | const msValue = ms.length >= 2 ? ms.substring(0, 2) : ms.padStart(2, "0"); 175 | return `${parseInt(hours || "0", 10)}:${minutes}:${seconds}.${msValue}`; 176 | }; 177 | 178 | // ASS 文件头模板 179 | export const assHeader = `[Script Info] 180 | Title: Bilingual Subtitles 181 | ScriptType: v4.00+ 182 | WrapStyle: 0 183 | ScaledBorderAndShadow: Yes 184 | PlayResX: 1920 185 | PlayResY: 1080 186 | Collisions: Normal 187 | 188 | [V4+ Styles] 189 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 190 | Style: Default,Noto Sans,70,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,30,30,35,1 191 | Style: Secondary,Noto Sans,55,&H003CF7F4,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,30,30,35,1 192 | 193 | [Events] 194 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`; 195 | -------------------------------------------------------------------------------- /src/app/components/languages.tsx: -------------------------------------------------------------------------------- 1 | interface LanguageOption { 2 | value: string; 3 | labelKey: string; 4 | nativeLabel: string; 5 | name: string; 6 | unsupportedMethods?: string[]; 7 | } 8 | 9 | // 部分语言不支持的翻译方法 10 | const DEEPL_METHODS = ["deepl", "deeplx"]; 11 | const Azure_DEEPL_METHODS = ["deepl", "deeplx", "azure"]; 12 | 13 | //autocorrect:false 14 | export const languages: LanguageOption[] = [ 15 | { value: "auto", labelKey: "languages.auto", nativeLabel: "Auto", name: "Auto" }, 16 | { value: "en", labelKey: "languages.english", nativeLabel: "English", name: "English" }, 17 | { value: "zh", labelKey: "languages.chinese", nativeLabel: "简体", name: "Simplified Chinese" }, 18 | { value: "zh-hant", labelKey: "languages.traditionalChinese", nativeLabel: "繁體", name: "Traditional Chinese" }, 19 | { value: "es", labelKey: "languages.spanish", nativeLabel: "Español", name: "Spanish" }, 20 | { value: "de", labelKey: "languages.german", nativeLabel: "Deutsch", name: "German" }, 21 | { value: "pt-br", labelKey: "languages.portugueseBrazil", nativeLabel: "Português (Brasil)", name: "Portuguese (Brazil)" }, 22 | { value: "pt-pt", labelKey: "languages.portuguesePortugal", nativeLabel: "Português (Portugal)", name: "Portuguese (Portugal)" }, 23 | { value: "ar", labelKey: "languages.arabic", nativeLabel: "العربية", name: "Arabic" }, 24 | { value: "ja", labelKey: "languages.japanese", nativeLabel: "日本語", name: "Japanese" }, 25 | { value: "ko", labelKey: "languages.korean", nativeLabel: "한국어", name: "Korean" }, 26 | { value: "ru", labelKey: "languages.russian", nativeLabel: "Русский", name: "Russian" }, 27 | { value: "fr", labelKey: "languages.french", nativeLabel: "Français", name: "French" }, 28 | { value: "it", labelKey: "languages.italian", nativeLabel: "Italiano", name: "Italian" }, 29 | { value: "tr", labelKey: "languages.turkish", nativeLabel: "Türkçe", name: "Turkish" }, 30 | { value: "pl", labelKey: "languages.polish", nativeLabel: "Polski", name: "Polish" }, 31 | { value: "uk", labelKey: "languages.ukrainian", nativeLabel: "Українська", name: "Ukrainian" }, 32 | { value: "ro", labelKey: "languages.romanian", nativeLabel: "Română", name: "Romanian" }, 33 | { value: "hu", labelKey: "languages.hungarian", nativeLabel: "Magyar", name: "Hungarian" }, 34 | { value: "cs", labelKey: "languages.czech", nativeLabel: "Čeština", name: "Czech" }, 35 | { value: "sk", labelKey: "languages.slovak", nativeLabel: "Slovenčina", name: "Slovak" }, 36 | { value: "bg", labelKey: "languages.bulgarian", nativeLabel: "Български", name: "Bulgarian" }, 37 | { value: "sv", labelKey: "languages.swedish", nativeLabel: "Svenska", name: "Swedish" }, 38 | { value: "da", labelKey: "languages.danish", nativeLabel: "Dansk", name: "Danish" }, 39 | { value: "fi", labelKey: "languages.finnish", nativeLabel: "Suomi", name: "Finnish" }, 40 | { value: "nb", labelKey: "languages.norwegian", nativeLabel: "Norsk bokmål", name: "Norwegian" }, 41 | { value: "lt", labelKey: "languages.lithuanian", nativeLabel: "Lietuvių", name: "Lithuanian" }, 42 | { value: "lv", labelKey: "languages.latvian", nativeLabel: "Latviešu", name: "Latvian" }, 43 | { value: "et", labelKey: "languages.estonian", nativeLabel: "Eesti", name: "Estonian" }, 44 | { value: "el", labelKey: "languages.greek", nativeLabel: "Ελληνικά", name: "Greek" }, 45 | { value: "sl", labelKey: "languages.slovenian", nativeLabel: "Slovenščina", name: "Slovenian" }, 46 | { value: "nl", labelKey: "languages.dutch", nativeLabel: "Nederlands", name: "Dutch" }, 47 | { value: "id", labelKey: "languages.indonesian", nativeLabel: "Bahasa Indonesia", name: "Indonesian" }, 48 | { value: "ms", labelKey: "languages.malay", nativeLabel: "Bahasa Melayu", name: "Malay", unsupportedMethods: DEEPL_METHODS }, 49 | { value: "vi", labelKey: "languages.vietnamese", nativeLabel: "Tiếng Việt", name: "Vietnamese", unsupportedMethods: DEEPL_METHODS }, 50 | { value: "hi", labelKey: "languages.hindi", nativeLabel: "हिन्दी", name: "Hindi", unsupportedMethods: DEEPL_METHODS }, 51 | { value: "bn", labelKey: "languages.bengali", nativeLabel: "বাংলা", name: "Bengali", unsupportedMethods: DEEPL_METHODS }, 52 | { value: "bho", labelKey: "languages.bhojpuri", nativeLabel: "भोजपुरी", name: "Bhojpuri", unsupportedMethods: DEEPL_METHODS }, 53 | { value: "mr", labelKey: "languages.marathi", nativeLabel: "मराठी", name: "Marathi", unsupportedMethods: DEEPL_METHODS }, 54 | { value: "gu", labelKey: "languages.gujarati", nativeLabel: "ગુજરાતી", name: "Gujarati", unsupportedMethods: DEEPL_METHODS }, 55 | { value: "ta", labelKey: "languages.tamil", nativeLabel: "தமிழ்", name: "Tamil", unsupportedMethods: DEEPL_METHODS }, 56 | { value: "te", labelKey: "languages.telugu", nativeLabel: "తెలుగు", name: "Telugu", unsupportedMethods: DEEPL_METHODS }, 57 | { value: "kn", labelKey: "languages.kannada", nativeLabel: "ಕನ್ನಡ", name: "Kannada", unsupportedMethods: DEEPL_METHODS }, 58 | { value: "th", labelKey: "languages.thai", nativeLabel: "ไทย", name: "Thai", unsupportedMethods: DEEPL_METHODS }, 59 | { value: "fil", labelKey: "languages.filipino", nativeLabel: "Filipino", name: "Filipino(Tagalog)", unsupportedMethods: DEEPL_METHODS }, 60 | { value: "jv", labelKey: "languages.javanese", nativeLabel: "Basa Jawa", name: "Javanese", unsupportedMethods: Azure_DEEPL_METHODS }, 61 | { value: "he", labelKey: "languages.hebrew", nativeLabel: "עברית", name: "Hebrew", unsupportedMethods: DEEPL_METHODS }, 62 | { value: "am", labelKey: "languages.amharic", nativeLabel: "አማርኛ", name: "Amharic", unsupportedMethods: DEEPL_METHODS }, 63 | { value: "fa", labelKey: "languages.persian", nativeLabel: "فارسی", name: "Persian", unsupportedMethods: DEEPL_METHODS }, 64 | { value: "ug", labelKey: "languages.uyghur", nativeLabel: "ئۇيغۇرچە", name: "Uyghur", unsupportedMethods: DEEPL_METHODS }, 65 | { value: "ha", labelKey: "languages.hausa", nativeLabel: "هَرْشٜىٰن هَوْسَا", name: "Hausa", unsupportedMethods: DEEPL_METHODS }, 66 | { value: "sw", labelKey: "languages.swahili", nativeLabel: "Kiswahili", name: "Swahili", unsupportedMethods: DEEPL_METHODS }, 67 | { value: "uz", labelKey: "languages.uzbek", nativeLabel: "Oʻzbekcha", name: "Uzbek", unsupportedMethods: DEEPL_METHODS }, 68 | { value: "kk", labelKey: "languages.kazakh", nativeLabel: "Қазақ тілі", name: "Kazakh", unsupportedMethods: DEEPL_METHODS }, 69 | { value: "ky", labelKey: "languages.kyrgyz", nativeLabel: "Кыргызча", name: "Kyrgyz", unsupportedMethods: DEEPL_METHODS }, 70 | { value: "tk", labelKey: "languages.turkmen", nativeLabel: "Türkmençe", name: "Turkmen", unsupportedMethods: DEEPL_METHODS }, 71 | { value: "ur", labelKey: "languages.urdu", nativeLabel: "اردو", name: "Urdu", unsupportedMethods: DEEPL_METHODS }, 72 | { value: "hr", labelKey: "languages.croatian", nativeLabel: "Hrvatski", name: "Croatian", unsupportedMethods: DEEPL_METHODS }, 73 | ]; 74 | 75 | import { useTranslations } from "next-intl"; 76 | 77 | export const useLanguageOptions = () => { 78 | const t = useTranslations(); 79 | 80 | // Create source options with translations 81 | const sourceOptions = languages.map((language) => ({ 82 | ...language, 83 | label: `${t(language.labelKey)} (${language.nativeLabel})`, 84 | })); 85 | 86 | // Create target options with translations (excluding "auto") 87 | const targetOptions = languages 88 | .filter((language) => language.value !== "auto") 89 | .map((language) => ({ 90 | ...language, 91 | label: `${t(language.labelKey)} (${language.nativeLabel})`, 92 | })); 93 | 94 | return { sourceOptions, targetOptions }; 95 | }; 96 | 97 | const normalizeText = (text = "") => text.trim().toLowerCase(); 98 | 99 | export const filterLanguageOption = ({ input, option }) => { 100 | const normalizedInput = normalizeText(input); 101 | const normalizedLabel = normalizeText(option?.label); 102 | const normalizedName = normalizeText(option?.name); 103 | 104 | // 如果 label 或 name 包含输入的内容,则返回 true 105 | return normalizedLabel.includes(normalizedInput) || normalizedName.includes(normalizedInput); 106 | }; 107 | -------------------------------------------------------------------------------- /src/app/components/projects.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BgColorsOutlined, 3 | DatabaseOutlined, 4 | ExperimentOutlined, 5 | ScissorOutlined, 6 | FileTextOutlined, 7 | FontSizeOutlined, 8 | CodeOutlined, 9 | GlobalOutlined, 10 | BookOutlined, 11 | FileSearchOutlined, 12 | EditOutlined, 13 | SwapOutlined, 14 | FileSyncOutlined, 15 | NodeIndexOutlined, 16 | VideoCameraOutlined, 17 | FileMarkdownOutlined, 18 | TranslationOutlined, 19 | LinkOutlined, 20 | UnorderedListOutlined, 21 | ProfileOutlined, 22 | OrderedListOutlined, 23 | ToolOutlined, 24 | } from "@ant-design/icons"; 25 | import Link from "next/link"; 26 | import { useTranslations } from "next-intl"; 27 | import { useLocale } from "next-intl"; 28 | 29 | // 排除当前项目 "subtitle-translator", 30 | const projectCategories = { 31 | translate: ["json-translate", "md-translator", "aishort-translate"], 32 | textParser: ["text-splitter", "chinese-conversion", "novel-processor", "regex-matcher", "text-processor"], 33 | jsonParser: ["json-value-extractor", "json-node-edit", "json-value-transformer", "json-value-swapper", "json-node-inserter", "json-sort-classify", "json-match-update"], 34 | dataParser: ["data-parser/flare", "data-parser/img-prompt"], 35 | }; 36 | 37 | export const projects = [ 38 | { 39 | titleKey: "tools.jsonTranslate.title", 40 | descriptionKey: "tools.jsonTranslate.description", 41 | key: "json-translate", 42 | icon: , 43 | }, 44 | { 45 | titleKey: "tools.subtitleTranslator.title", 46 | descriptionKey: "tools.subtitleTranslator.description", 47 | key: "subtitle-translator", 48 | icon: , 49 | }, 50 | { 51 | titleKey: "tools.mdTranslator.title", 52 | descriptionKey: "tools.mdTranslator.description", 53 | key: "md-translator", 54 | icon: , 55 | }, 56 | { 57 | titleKey: "tools.textSplitter.title", 58 | descriptionKey: "tools.textSplitter.description", 59 | key: "text-splitter", 60 | icon: , 61 | }, 62 | { 63 | titleKey: "简繁转换", 64 | descriptionKey: "批量转换简体、台湾繁体、香港繁体和日本新字体", 65 | key: "chinese-conversion", 66 | icon: , 67 | onlyzh: true, 68 | }, 69 | { 70 | titleKey: "正则文本助手", 71 | descriptionKey: "集成正则匹配、排序、过滤等功能,进行文本批量处理", 72 | key: "regex-matcher", 73 | icon: , 74 | onlyzh: true, 75 | }, 76 | { 77 | titleKey: "小说文本处理", 78 | descriptionKey: "批量处理不规范格式的小说长文本", 79 | key: "novel-processor", 80 | icon: , 81 | onlyzh: true, 82 | }, 83 | { 84 | titleKey: "自用文本处理", 85 | descriptionKey: "自用多种规则的文本处理工具", 86 | key: "text-processor", 87 | icon: , 88 | onlyzh: true, 89 | }, 90 | { 91 | titleKey: "tools.jsonValueExtractor.title", 92 | descriptionKey: "tools.jsonValueExtractor.description", 93 | key: "json-value-extractor", 94 | icon: , 95 | }, 96 | { 97 | titleKey: "tools.jsonNodeEdit.title", 98 | descriptionKey: "tools.jsonNodeEdit.description", 99 | key: "json-node-edit", 100 | icon: , 101 | }, 102 | { 103 | titleKey: "tools.jsonValueTransformer.title", 104 | descriptionKey: "tools.jsonValueTransformer.description", 105 | key: "json-value-transformer", 106 | icon: , 107 | }, 108 | { 109 | titleKey: "tools.jsonValueSwapper.title", 110 | descriptionKey: "tools.jsonValueSwapper.description", 111 | key: "json-value-swapper", 112 | icon: , 113 | }, 114 | { 115 | titleKey: "tools.jsonNodeInserter.title", 116 | descriptionKey: "tools.jsonNodeInserter.description", 117 | key: "json-node-inserter", 118 | icon: , 119 | }, 120 | { 121 | titleKey: "tools.jsonSortClassify.title", 122 | descriptionKey: "tools.jsonSortClassify.description", 123 | key: "json-sort-classify", 124 | icon: , 125 | }, 126 | { 127 | titleKey: "tools.jsonMatchUpdate.title", 128 | descriptionKey: "tools.jsonMatchUpdate.description", 129 | key: "json-match-update", 130 | icon: , 131 | }, 132 | { 133 | titleKey: "tools.dataParserFlare.title", 134 | descriptionKey: "tools.dataParserFlare.description", 135 | key: "data-parser/flare", 136 | icon: , 137 | }, 138 | { 139 | titleKey: "tools.dataParserImgPrompt.title", 140 | descriptionKey: "tools.dataParserImgPrompt.description", 141 | key: "data-parser/img-prompt", 142 | icon: , 143 | }, 144 | { 145 | titleKey: "AIShort 多语言翻译", 146 | descriptionKey: "一键翻译 ChatGPT Shortcut 13 种语言", 147 | key: "aishort-translate", 148 | icon: , 149 | onlyzh: true, 150 | }, 151 | ]; 152 | 153 | const projectsMap = projects.reduce((acc, project) => { 154 | acc[project.key] = project; 155 | return acc; 156 | }, {}); 157 | 158 | export const AppMenu = () => { 159 | const t = useTranslations(); 160 | const locale = useLocale(); 161 | const isChineseLocale = locale === "zh" || locale === "zh-hant"; 162 | 163 | const createMenuItem = (projectKey) => { 164 | const project = projectsMap[projectKey]; 165 | if (!project || (project.onlyzh && locale !== "zh")) { 166 | return null; 167 | } 168 | return { 169 | label: {project.onlyzh && locale === "zh" ? project.titleKey : t(project.titleKey)}, 170 | key: project.key, 171 | icon: project.icon, 172 | }; 173 | }; 174 | 175 | const generateCategoryItems = (categoryKeys) => { 176 | return categoryKeys.map(createMenuItem).filter(Boolean); 177 | }; 178 | 179 | const otherToolsItems = [ 180 | { 181 | label: ( 182 | 183 | ChatGPT Shortcut 184 | 185 | ), 186 | key: "aishort", 187 | icon: , 188 | }, 189 | { 190 | label: ( 191 | 192 | IMGPrompt 193 | 194 | ), 195 | key: "IMGPrompt", 196 | icon: , 197 | }, 198 | ]; 199 | 200 | if (isChineseLocale) { 201 | otherToolsItems.push({ 202 | label: ( 203 | 204 | LearnData 开源笔记 205 | 206 | ), 207 | key: "LearnData", 208 | icon: , 209 | }); 210 | } 211 | 212 | const menuItems = [ 213 | { 214 | label: {t("tools.subtitleTranslator.title")}, 215 | key: "subtitle-translator", 216 | }, 217 | { 218 | label: t("navigation.translate"), 219 | key: "translate", 220 | icon: , 221 | children: generateCategoryItems(projectCategories.translate), 222 | }, 223 | { 224 | label: t("navigation.textParser"), 225 | key: "textParser", 226 | icon: , 227 | children: generateCategoryItems(projectCategories.textParser), 228 | }, 229 | { 230 | label: t("navigation.jsonParser"), 231 | key: "jsonParser", 232 | icon: , 233 | children: generateCategoryItems(projectCategories.jsonParser), 234 | }, 235 | { 236 | label: t("navigation.dataParser"), 237 | key: "dataParser", 238 | icon: , 239 | children: generateCategoryItems(projectCategories.dataParser), 240 | }, 241 | { 242 | label: t("navigation.otherTools"), 243 | key: "otherTools", 244 | icon: , 245 | children: otherToolsItems, 246 | }, 247 | { 248 | label: {t("feedback.feedback1")}, 249 | key: "feedback", 250 | }, 251 | ]; 252 | 253 | return menuItems; 254 | }; 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ⚡️Subtitle Translator 3 |

4 |

5 | English | 中文 6 |

7 |

8 | Translate subtitles effortlessly—fast, accurate, and multilingual! 9 |

10 | 11 | **Subtitle Translator** is a **free and open-source** batch subtitle translation tool that supports `.srt`, `.ass`, and `.vtt` formats. With **real-time translation speeds**, it leverages multiple **translation APIs and AI models** to quickly translate subtitle files into **50 languages**, including the ability to **translate a single subtitle file into multiple languages at once** for global accessibility. 12 | 13 | Compared to traditional subtitle translation tools, Subtitle Translator excels with its **batch processing, high-speed translation, translation caching, and automatic format adaptation**, significantly improving workflow efficiency. It is ideal for use in film and TV, education, and content creation. 14 | 15 | 👉 **Try it online**: 16 | 17 | ## Key Features 18 | 19 | !["Batch Translation"](https://img.newzone.top/subtile-translator.gif?imageMogr2/format/webp "Batch Translation") 20 | 21 | - **Real-time translation**: Uses **chunked compression** and **parallel processing** to achieve **1-second translation per episode** (GTX interface is slightly slower). 22 | - **Batch processing**: Handles **hundreds of subtitle files at once**, significantly boosting efficiency. 23 | - **Translation caching**: Automatically **stores translation results locally**, avoiding redundant API calls and saving both time and costs. 24 | - **Format compatibility**: **Automatically detects and adapts** to `.srt`, `.ass`, and `.vtt` subtitle formats, preserving the original file name. 25 | - **Subtitle extraction**: Allows **easy text extraction** for use in AI summarization, content repurposing, and more. 26 | - **Multiple translation options**: Supports **3 free translation APIs, 3 commercial-grade APIs, and 5 AI LLM (large language model) interfaces**, catering to different needs. 27 | - **Multi-language support & internationalization**: Translates subtitles into **50 major languages**, including English, Chinese, Japanese, Korean, French, German, and Spanish. It also supports **multi-language translations from a single file**, generating **bilingual or multilingual subtitles**. 28 | 29 | Subtitle Translator offers a range of customizable parameters to meet diverse user needs. Below is a detailed explanation of its features. 30 | 31 | ## Translation APIs 32 | 33 | Subtitle Translator supports **5 translation APIs** and **5 AI LLM models**, allowing users to choose the best option for their needs. 34 | 35 | ### API Comparison 36 | 37 | | API | Translation Quality | Stability | Best Use Case | Free Tier | 38 | | -------------------- | ------------------- | --------- | ---------------------------------------- | ------------------------------------------------ | 39 | | **DeepL (X)** | ★★★★★ | ★★★★☆ | Best for long texts, fluent translations | 500,000 characters/month | 40 | | **Google Translate** | ★★★★☆ | ★★★★★ | Best for UI text and common phrases | 500,000 characters/month | 41 | | **Azure Translate** | ★★★★☆ | ★★★★★ | Best for multi-language support | **2 million characters/month** (first 12 months) | 42 | | **GTX API (Free)** | ★★★☆☆ | ★★★☆☆ | General translation tasks | Free | 43 | | **GTX Web (Free)** | ★★★☆☆ | ★★☆☆☆ | Small-scale translations | Free | 44 | 45 | - **DeepL**: Ideal for long-form content, offering **more fluent** translations, but requires local or server proxy usage. 46 | - **Google Translate**: **Stable and widely used**, best for **short sentences and UI text**. 47 | - **Azure Translate**: **Supports the most languages**, making it the best option for **multi-language translations**. 48 | - **GTX API/Web**: Free translation options, suitable for **light usage** but with **limited stability**. 49 | 50 | 🔹 **API Key Registration**: [Google Translate](https://cloud.google.com/translate/docs/setup?hl=zh-cn), [Azure Translate](https://learn.microsoft.com/zh-cn/azure/ai-services/translator/reference/v3-0-translate), [DeepL Translate](https://www.deepl.com/your-account/keys) 51 | 52 | 🔹 **Supported Languages**: [DeepL](https://developers.deepl.com/docs/v/zh/api-reference/languages), [Google Translate](https://cloud.google.com/translate/docs/languages?hl=zh-cn), [Azure](https://learn.microsoft.com/zh-cn/azure/ai-services/translator/language-support) 53 | 54 | ### LLM Translation (AI Models) 55 | 56 | Subtitle Translator also supports **5 AI LLM models**, including **OpenAI, DeepSeek, Siliconflow, and Groq**. 57 | 58 | - **Best for**: **Literary works, technical documents, and multilingual dialogue**. 59 | - **Customization**: Supports **system prompts and user prompts**, allowing personalized translation styles. 60 | - **Temperature control**: Adjusts **AI translation creativity**, where **higher values produce more diverse translations** but may reduce consistency. 61 | 62 | ## Subtitle Format Support 63 | 64 | Subtitle Translator supports **`.srt`, `.ass`, and `.vtt` formats** with **automatic format detection and adaptation**: 65 | 66 | - **Bilingual subtitles**: Translated text **can be inserted below the original** and its position can be adjusted. 67 | - **Timeline compatibility**: Supports **over 100-hour timestamps**, along with **1-3 digit millisecond formats** to ensure seamless synchronization. 68 | - **Automatic encoding detection**: Prevents **character encoding issues** by detecting and adjusting encoding settings automatically. 69 | 70 | ## Translation Modes 71 | 72 | Subtitle Translator offers **batch translation** and **single-file translation**, adapting to different workflows: 73 | 74 | ✅ **Batch Translation (Default Mode)** 75 | 76 | - **Processes hundreds of files simultaneously**, maximizing efficiency. 77 | - **Translated files are automatically saved** in the browser’s default download folder. 78 | 79 | ✅ **Single-File Mode** (For quick tasks) 80 | 81 | - **Allows direct text input and translation**. 82 | - **Results are displayed instantly**, with the option to **copy or export**. 83 | - Uploading a new file **will replace the previous file**. 84 | 85 | ## Translation Caching 86 | 87 | Subtitle Translator **employs local caching** to optimize efficiency: 88 | 89 | - **Caching rules**: Translation results are stored using a unique key format: 90 | `original_text_target_language_source_language_API_model_settings` 91 | - **Efficiency boost**: **Avoids redundant translations, reducing API calls and speeding up workflows**. 92 | 93 | ## Multi-Language Translation 94 | 95 | Subtitle Translator allows **translating the same subtitle file into multiple languages at once**, ideal for internationalization. 96 | 97 | For example: 98 | 99 | - Translate an **English subtitle** into **Chinese, Japanese, German, and French** simultaneously for global accessibility. 100 | - Supports **50 major languages**, with more to be added based on user feedback. 101 | 102 | ## Usage Notes 103 | 104 | When using Subtitle Translator, keep in mind: 105 | 106 | - **DeepL API does not support web-based usage**. Instead, Subtitle Translator **provides a dedicated server-side proxy** for DeepL translations, ensuring security and efficiency. Users can also **deploy the proxy locally**. 107 | - **Subtitle Translator does not store API keys**—all data remains **locally cached in your browser** for privacy. 108 | - **GTX Web API runs locally** to prevent server overload. Avoid using GTX Web in **global proxy mode** to prevent translation errors. 109 | 110 | ## Future Updates 111 | 112 | 🚀 **Upcoming Features**: 113 | ✅ **Standalone desktop version** 114 | ✅ **AI-powered translation refinement** 115 | 116 | Subtitle Translator will continue to evolve based on user feedback. If you find this tool helpful, feel free to contribute or suggest improvements! 🚀 117 | 118 | ## Deployment 119 | 120 | Subtitle Translator can be deployed on Cloudflare, Vercel, EdgeOne, or any server. 121 | 122 | [![Use EdgeOne Pages to deploy](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Frockbenben%2Fsubtitle-translator&output-directory=out&install-command=yarn+install&build-command=yarn+build%3Alang+en) 123 | 124 | System Requirements: 125 | 126 | - [Node.js 18.18](https://nodejs.org/) or later. 127 | - macOS, Windows (including WSL), and Linux are supported. 128 | 129 | ```shell 130 | # Installation 131 | yarn 132 | 133 | # Local Development 134 | yarn dev 135 | 136 | # build and start 137 | yarn build && npx serve@latest out 138 | 139 | # Deploy for a single language 140 | yarn build:lang en 141 | yarn build:lang zh 142 | yarn build:lang zh-hant 143 | ``` 144 | 145 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 146 | 147 | You can start editing the page by modifying `src/app/[locale]/page.tsx`. The page auto-updates as you edit the file. 148 | -------------------------------------------------------------------------------- /src/app/ui/Navigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useEffect, useMemo } from "react"; 3 | import { usePathname, useRouter } from "next/navigation"; 4 | import { Layout, Menu, Space, ConfigProvider, theme as antTheme, Button, Dropdown, Input } from "antd"; 5 | import type { MenuProps } from "antd"; 6 | import { GithubOutlined, QqOutlined, DiscordOutlined, TranslationOutlined, SunOutlined, MoonOutlined, CheckOutlined } from "@ant-design/icons"; 7 | import { useTheme } from "next-themes"; 8 | import { useLocale } from "next-intl"; 9 | import { AppMenu } from "@/app/components/projects"; 10 | 11 | const { Header } = Layout; 12 | 13 | const LANGUAGES = [ 14 | { key: "zh", label: "中文" }, 15 | { key: "en", label: "English" }, 16 | { key: "es", label: "Español" }, 17 | { key: "hi", label: "हिन्दी" }, 18 | { key: "ar", label: "العربية" }, 19 | { key: "pt", label: "Português" }, 20 | { key: "fr", label: "Français" }, 21 | { key: "de", label: "Deutsch" }, 22 | { key: "ja", label: "日本語" }, 23 | { key: "ko", label: "한국어" }, 24 | { key: "ru", label: "Русский" }, 25 | { key: "vi", label: "Tiếng Việt" }, 26 | { key: "tr", label: "Türkçe" }, 27 | { key: "zh-hant", label: "繁体中文" }, 28 | { key: "bn", label: "বাংলা" }, 29 | { key: "id", label: "Indonesia" }, 30 | { key: "it", label: "Italiano" }, 31 | ] as const; 32 | 33 | const SOCIAL_LINKS = { 34 | github: "https://github.com/rockbenben/subtitle-translator", 35 | discord: "https://discord.gg/PZTQfJ4GjX", 36 | qq: "https://qm.qq.com/q/qvephMO8q4", 37 | } as const; 38 | 39 | export function Navigation() { 40 | const menuItems = AppMenu(); 41 | const pathname = usePathname(); 42 | const router = useRouter(); 43 | const [mounted, setMounted] = useState(false); 44 | const { theme, setTheme } = useTheme(); 45 | const { token } = antTheme.useToken(); 46 | const locale = useLocale(); 47 | const [langOpen, setLangOpen] = useState(false); 48 | const [langQuery, setLangQuery] = useState(""); 49 | 50 | const [current, setCurrent] = useState(pathname); 51 | const isChineseLocale = locale === "zh" || locale === "zh-hant"; 52 | const currentLanguage = LANGUAGES.find((l) => l.key === locale)?.label || "English"; 53 | 54 | // 语言筛选与布局计算(需放在 early return 之前,避免违反 Hooks 规则) 55 | const filteredLanguages = useMemo(() => { 56 | const q = langQuery.trim().toLowerCase(); 57 | if (!q) return LANGUAGES as readonly { key: string; label: string }[]; 58 | return LANGUAGES.filter((l) => l.label.toLowerCase().includes(q) || l.key.toLowerCase().includes(q)); 59 | }, [langQuery]); 60 | const langGridCols = LANGUAGES.length > 16 ? 3 : 2; 61 | // 三列时适当加宽:按列数与单列最小宽度动态计算弹层宽度 62 | const perColMin = 140; // 每列最小宽度(px),避免标签被过度挤压 63 | const panelGap = 6; // 与 grid gap 保持一致 64 | const panelPadding = 16; // 外层 padding: 8 上下合计 65 | const langPanelWidth = Math.min( 66 | 680, // 上限,防止过宽 67 | Math.max(420, langGridCols * perColMin + (langGridCols - 1) * panelGap + panelPadding) 68 | ); 69 | 70 | useEffect(() => { 71 | setMounted(true); 72 | }, []); 73 | 74 | if (!mounted) return null; 75 | 76 | const handleThemeToggle = () => setTheme(theme === "light" ? "dark" : "light"); 77 | const handleMenuClick: MenuProps["onClick"] = (e) => setCurrent(e.key); 78 | 79 | const handleLanguageChange = (key: string) => { 80 | const newPath = pathname.replace(/^\/[a-z]{2}(-[a-z]+)?/, `/${key}`); 81 | router.push(newPath); 82 | }; 83 | 84 | const handleLanguageMenuClick: MenuProps["onClick"] = (e) => { 85 | handleLanguageChange(e.key); 86 | }; 87 | 88 | const getSocialIconStyle = () => ({ 89 | fontSize: token.fontSizeXL, 90 | padding: token.paddingXS, 91 | color: theme === "light" ? token.colorText : token.colorTextLightSolid, 92 | }); 93 | 94 | const bgColor = theme === "light" ? token.colorBgContainer : token.colorBgLayout; 95 | 96 | return ( 97 | 107 |
113 |
120 | 132 | 133 | 134 | ( 142 |
152 | } 158 | value={langQuery} 159 | autoFocus={langOpen} 160 | onChange={(e) => setLangQuery(e.target.value)} 161 | /> 162 |
172 | {filteredLanguages.map((lang) => { 173 | const selected = lang.key === locale; 174 | return ( 175 | 195 | ); 196 | })} 197 | {filteredLanguages.length === 0 &&
No match
} 198 |
199 |
200 | )} 201 | placement="bottomRight"> 202 | 205 |
206 | 207 | 208 | {isChineseLocale && ( 209 | 210 | 211 | 212 | )} 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 |
232 |
233 |
234 | ); 235 | } 236 | 237 | export default Navigation; 238 | -------------------------------------------------------------------------------- /src/app/components/TranslationSettings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Tabs, Form, Input, InputNumber, Card, Typography, Button, Space, Tooltip, message } from "antd"; 5 | import { TRANSLATION_SERVICES, LLM_MODELS, CACHE_PREFIX, testTranslation } from "@/app/components/translateAPI"; 6 | import useTranslateData from "@/app/hooks/useTranslateData"; 7 | import { useTranslations } from "next-intl"; 8 | 9 | const { Text, Link } = Typography; 10 | const { TextArea } = Input; 11 | 12 | const TranslationSettings = () => { 13 | const tCommon = useTranslations("common"); 14 | const t = useTranslations("TranslationSettings"); 15 | const [messageApi, contextHolder] = message.useMessage(); 16 | const { translationMethod, setTranslationMethod, translationConfigs, getCurrentConfig, handleConfigChange, resetTranslationConfig, sysPrompt, setSysPrompt, userPrompt, setUserPrompt } = 17 | useTranslateData(); 18 | const [testingService, setTestingService] = useState(null); 19 | const resetTranslationCache = async () => { 20 | try { 21 | // 异步分批删除缓存,避免UI阻塞 22 | const allKeys = Object.keys(localStorage); 23 | const cacheKeys = allKeys.filter((key) => key.startsWith(CACHE_PREFIX)); 24 | 25 | // 分批处理,每批删除100个 26 | const batchSize = 100; 27 | for (let i = 0; i < cacheKeys.length; i += batchSize) { 28 | const batch = cacheKeys.slice(i, i + batchSize); 29 | batch.forEach((key) => localStorage.removeItem(key)); 30 | 31 | // 让出控制权给UI线程 32 | if (i + batchSize < cacheKeys.length) { 33 | await new Promise((resolve) => setTimeout(resolve, 0)); 34 | } 35 | } 36 | 37 | messageApi.success(`Translation cache has been reset (${cacheKeys.length} entries cleared)`); 38 | } catch (error) { 39 | console.error("Failed to clear cache:", error); 40 | messageApi.error("Failed to clear translation cache"); 41 | } 42 | }; 43 | const handleTabChange = (key: string) => { 44 | setTranslationMethod(key); 45 | }; 46 | const handleTestConfig = async (service: string, serviceLabel?: string) => { 47 | const config = translationConfigs?.[service]; 48 | if (!config) { 49 | messageApi.error(t("testConfigFail")); 50 | return; 51 | } 52 | 53 | if (config.apiKey !== undefined && service !== "llm" && !`${config.apiKey}`.trim()) { 54 | messageApi.error(tCommon("enterApiKey")); 55 | return; 56 | } 57 | 58 | if (config.url !== undefined) { 59 | const urlValue = `${config.url ?? ""}`.trim(); 60 | if (!urlValue && (service === "llm" || service === "azureopenai")) { 61 | messageApi.error(tCommon("enterLlmUrl")); 62 | return; 63 | } 64 | } 65 | 66 | try { 67 | setTestingService(service); 68 | const isLLMService = LLM_MODELS.includes(service); 69 | const isSuccess = await testTranslation(service, config, isLLMService ? sysPrompt : undefined, isLLMService ? userPrompt : undefined); 70 | if (isSuccess) { 71 | messageApi.success(`${serviceLabel || service} - ${t("testConfigSuccess")}`); 72 | } else { 73 | messageApi.error(t("testConfigFail")); 74 | } 75 | } catch (error) { 76 | console.error("Test config failed", error); 77 | messageApi.error(t("testConfigFail")); 78 | } finally { 79 | setTestingService(null); 80 | } 81 | }; 82 | 83 | const renderSettings = (service: string) => { 84 | const currentService = TRANSLATION_SERVICES.find((s) => s.value === service); 85 | const config = getCurrentConfig(); 86 | const isLLMModel = LLM_MODELS.includes(service); 87 | 88 | return ( 89 |
90 | 93 | {currentService?.label} 94 | {currentService?.docs && ( 95 | 96 | {`API ${t("docs")}`} 97 | 98 | )} 99 | 100 | } 101 | extra={ 102 | 103 | 104 | 105 | 106 | 107 | 110 | 111 | 112 | 113 | }> 114 |
115 | {config?.url !== undefined && ( 116 | 120 | handleConfigChange(service, "url", e.target.value)} 130 | /> 131 | 132 | )} 133 | 134 | {config?.apiKey !== undefined && ( 135 | 136 | handleConfigChange(service, "apiKey", e.target.value)} 141 | /> 142 | 143 | )} 144 | 145 | {config?.region !== undefined && ( 146 | 147 | handleConfigChange(service, "region", e.target.value)} /> 148 | 149 | )} 150 | 151 | {config?.model !== undefined && ( 152 | 153 | handleConfigChange(service, "model", e.target.value)} /> 154 | 155 | )} 156 | 157 | {config?.apiVersion !== undefined && ( 158 | 159 | handleConfigChange(service, "apiVersion", e.target.value)} /> 160 | 161 | )} 162 | {config?.temperature !== undefined && ( 163 | 164 | handleConfigChange(service, "temperature", value ?? 0)} 170 | style={{ width: "100%" }} 171 | /> 172 | 173 | )} 174 | {isLLMModel && ( 175 | <> 176 | 177 |