├── video-translation-frontend
├── src
│ ├── vite-env.d.ts
│ ├── main.tsx
│ ├── index.css
│ ├── i18n.ts
│ ├── components
│ │ └── LanguageSelector.tsx
│ ├── assets
│ │ └── react.svg
│ ├── App.css
│ └── App.tsx
├── public
│ ├── images
│ │ ├── favicon.ico
│ │ └── 4p6vr8j7vbom4axo7k0 2.png
│ ├── vite.svg
│ └── locales
│ │ ├── zh
│ │ └── translation.json
│ │ ├── ko
│ │ └── translation.json
│ │ ├── ja
│ │ └── translation.json
│ │ └── en
│ │ └── translation.json
├── tsconfig.json
├── vite.config.ts
├── index.html
├── .gitignore
├── tsconfig.node.json
├── tsconfig.app.json
├── eslint.config.js
├── package.json
└── README.md
├── video-translation-webhook-backend
├── .env.example
├── requirements.txt
├── .gitignore
└── app.py
├── .gitignore
└── README.md
/video-translation-frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/video-translation-webhook-backend/.env.example:
--------------------------------------------------------------------------------
1 | CLIENT_ID = Client_id_here
2 | CLIENT_SECRET = Client_secret_here
--------------------------------------------------------------------------------
/video-translation-frontend/public/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AKOOL-Official/akool-video-translation-demo/HEAD/video-translation-frontend/public/images/favicon.ico
--------------------------------------------------------------------------------
/video-translation-frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/video-translation-frontend/public/images/4p6vr8j7vbom4axo7k0 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AKOOL-Official/akool-video-translation-demo/HEAD/video-translation-frontend/public/images/4p6vr8j7vbom4axo7k0 2.png
--------------------------------------------------------------------------------
/video-translation-webhook-backend/requirements.txt:
--------------------------------------------------------------------------------
1 | flask==3.0.2
2 | flask-cors==4.0.0
3 | flask-socketio==5.3.6
4 | python-dotenv==1.0.1
5 | pycryptodome==3.20.0
6 | gevent==24.2.1
7 | gevent-websocket==0.10.1
--------------------------------------------------------------------------------
/video-translation-frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/video-translation-webhook-backend/.gitignore:
--------------------------------------------------------------------------------
1 | # Environment variables
2 | .env
3 | .env.local
4 | .env.*
5 | *.env
6 |
7 | # Python
8 | __pycache__/
9 | *.py[cod]
10 | *$py.class
11 | *.so
12 | .Python
13 | venv/
14 | env/
15 | ENV/
16 | .venv
17 | *.egg-info/
18 | dist/
19 | build/
20 | *.egg
21 |
22 | # IDE
23 | .vscode/
24 | .idea/
25 | *.swp
26 | *.swo
27 | *~
28 |
29 | # OS
30 | .DS_Store
31 | Thumbs.db
32 |
33 | # Logs
34 | *.log
35 |
36 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/video-translation-frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | proxy: {
9 | '/api/open': {
10 | target: 'https://openapi.akool.com',
11 | changeOrigin: true,
12 | secure: true,
13 | rewrite: (path) => path,
14 | },
15 | },
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/video-translation-frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Video Translation Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/video-translation-frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Environment variables
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 | .env.*
22 |
23 | # Editor directories and files
24 | .vscode/*
25 | !.vscode/extensions.json
26 | .idea
27 | .DS_Store
28 | *.suo
29 | *.ntvs*
30 | *.njsproj
31 | *.sln
32 | *.sw?
33 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 | import LanguageDetector from 'i18next-browser-languagedetector';
4 | import Backend from 'i18next-http-backend';
5 |
6 | i18n
7 | .use(Backend)
8 | .use(LanguageDetector)
9 | .use(initReactI18next)
10 | .init({
11 | fallbackLng: 'en',
12 | debug: false,
13 | interpolation: {
14 | escapeValue: false,
15 | },
16 | backend: {
17 | loadPath: '/locales/{{lng}}/{{ns}}.json',
18 | }
19 | });
20 |
21 | export default i18n;
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Environment variables
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 | .env.*
22 |
23 | # Python
24 | __pycache__/
25 | *.py[cod]
26 | *$py.class
27 | *.so
28 | .Python
29 | venv/
30 | env/
31 | ENV/
32 | .venv
33 | *.egg-info/
34 | dist/
35 | build/
36 | *.egg
37 |
38 | # Editor directories and files
39 | .vscode/*
40 | !.vscode/extensions.json
41 | .idea
42 | .DS_Store
43 | *.suo
44 | *.ntvs*
45 | *.njsproj
46 | *.sln
47 | *.sw?
48 |
--------------------------------------------------------------------------------
/video-translation-frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/video-translation-frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/video-translation-frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/components/LanguageSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | const LanguageSelector = () => {
5 | const { i18n } = useTranslation();
6 |
7 | const changeLanguage = (event: React.ChangeEvent) => {
8 | const language = event.target.value;
9 | i18n.changeLanguage(language);
10 | localStorage.setItem('language', language);
11 | };
12 |
13 | return (
14 |
15 |
25 |
26 | );
27 | };
28 |
29 | export default LanguageSelector;
--------------------------------------------------------------------------------
/video-translation-frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "testapp",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "axios": "^1.7.9",
14 | "i18next": "^24.2.3",
15 | "i18next-browser-languagedetector": "^8.0.4",
16 | "i18next-http-backend": "^3.0.2",
17 | "react": "^18.3.1",
18 | "react-dom": "^18.3.1",
19 | "react-i18next": "^15.4.1",
20 | "socket.io-client": "^4.8.1"
21 | },
22 | "devDependencies": {
23 | "@eslint/js": "^9.15.0",
24 | "@types/react": "^18.3.12",
25 | "@types/react-dom": "^18.3.1",
26 | "@vitejs/plugin-react-swc": "^3.5.0",
27 | "eslint": "^9.15.0",
28 | "eslint-plugin-react-hooks": "^5.0.0",
29 | "eslint-plugin-react-refresh": "^0.4.14",
30 | "globals": "^15.12.0",
31 | "typescript": "~5.6.2",
32 | "typescript-eslint": "^8.15.0",
33 | "vite": "^6.0.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/video-translation-frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/video-translation-frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default tseslint.config({
18 | languageOptions: {
19 | // other options...
20 | parserOptions: {
21 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
22 | tsconfigRootDir: import.meta.dirname,
23 | },
24 | },
25 | })
26 | ```
27 |
28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29 | - Optionally add `...tseslint.configs.stylisticTypeChecked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31 |
32 | ```js
33 | // eslint.config.js
34 | import react from 'eslint-plugin-react'
35 |
36 | export default tseslint.config({
37 | // Set the react version
38 | settings: { react: { version: '18.3' } },
39 | plugins: {
40 | // Add the react plugin
41 | react,
42 | },
43 | rules: {
44 | // other rules...
45 | // Enable its recommended rules
46 | ...react.configs.recommended.rules,
47 | ...react.configs['jsx-runtime'].rules,
48 | },
49 | })
50 | ```
51 |
--------------------------------------------------------------------------------
/video-translation-frontend/public/locales/zh/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "appTitle": "AI视频翻译",
3 | "auth": {
4 | "apiKey": "x-api-key",
5 | "enterApiKey": "输入您的x-api-key",
6 | "clientCredentials": "客户端凭证",
7 | "clientId": "客户端ID",
8 | "enterClientId": "输入您的客户端ID",
9 | "clientSecret": "客户端密钥",
10 | "enterClientSecret": "输入您的客户端密钥",
11 | "getToken": "获取令牌",
12 | "fetchingToken": "正在获取令牌..."
13 | },
14 | "selectSourceLanguage": "选择源语言",
15 | "selectTargetLanguage": "选择目标语言",
16 | "selectVoice": "选择语音",
17 | "videoUrl": "视频链接",
18 | "enterVideoUrl": "输入视频链接",
19 | "enableLipSync": "启用唇形同步",
20 | "translate": "翻译",
21 | "translating": "正在翻译...",
22 | "downloadVideo": "下载视频",
23 | "translateAnother": "翻译另一个",
24 | "videoPreview": {
25 | "title": "视频预览",
26 | "placeholder": "在上方输入视频链接以预览",
27 | "error": "无法加载视频,请检查链接"
28 | },
29 | "languageSelection": {
30 | "title": "语言选择"
31 | },
32 | "basicOptions": {
33 | "title": "基本选项",
34 | "speakerNum": "说话者数量",
35 | "autoDetect": "自动检测",
36 | "dynamicVideoLength": "动态视频长度"
37 | },
38 | "advancedSettings": {
39 | "title": "高级设置",
40 | "removeBgm": "移除背景音乐",
41 | "captionType": "字幕",
42 | "captionFileUrl": "字幕文件URL (SRT/ASS)",
43 | "captionFileUrlHint": "仅支持SRT和ASS格式",
44 | "captionOptions": {
45 | "none": "无",
46 | "addOriginal": "添加原始语言字幕",
47 | "addTarget": "添加目标语言字幕",
48 | "translateReplace": "翻译并替换原始字幕",
49 | "addTranslated": "添加翻译字幕同时保留原始字幕"
50 | }
51 | },
52 | "loading": {
53 | "languages": "正在加载语言...",
54 | "voices": "正在加载语音..."
55 | },
56 | "processing": {
57 | "title": "视频处理中",
58 | "message": "您的视频正在处理中,请耐心等待",
59 | "statuses": {
60 | "queueing": "排队中",
61 | "processing": "处理中",
62 | "completed": "已完成",
63 | "failed": "失败"
64 | }
65 | },
66 | "results": {
67 | "title": "翻译结果",
68 | "singleVideo": "您的翻译视频已准备就绪!",
69 | "multipleVideos": "您的 {{count}} 个翻译视频已准备就绪!",
70 | "backToGallery": "返回图库",
71 | "galleryHint": "点击任意视频以预览和下载",
72 | "videosReady": "{{completed}} / {{total}} 个视频已就绪",
73 | "videosReadyWithFailed": "{{completed}} 个视频已就绪,{{failed}} 个失败"
74 | },
75 | "errors": {
76 | "invalidInput": "请提供有效的视频链接、选择源语言和目标语言",
77 | "fetchError": "获取语言时发生错误",
78 | "translationError": "翻译视频时发生错误",
79 | "apiKeyError": "请输入有效的x-api-key",
80 | "tokenError": "请输入客户端ID和客户端密钥",
81 | "voiceRequired": "请为目标语言选择语音",
82 | "apiError": "API错误"
83 | },
84 | "buttons": {
85 | "submitApiKey": "提交x-api-key",
86 | "ok": "确定"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/video-translation-frontend/public/locales/ko/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "appTitle": "AI 비디오 번역기",
3 | "auth": {
4 | "apiKey": "x-api-key",
5 | "enterApiKey": "x-api-key를 입력하세요",
6 | "clientCredentials": "클라이언트 자격 증명",
7 | "clientId": "클라이언트 ID",
8 | "enterClientId": "클라이언트 ID를 입력하세요",
9 | "clientSecret": "클라이언트 시크릿",
10 | "enterClientSecret": "클라이언트 시크릿을 입력하세요",
11 | "getToken": "토큰 받기",
12 | "fetchingToken": "토큰을 가져오는 중..."
13 | },
14 | "selectSourceLanguage": "원본 언어 선택",
15 | "selectTargetLanguage": "대상 언어 선택",
16 | "selectVoice": "음성 선택",
17 | "videoUrl": "비디오 URL",
18 | "enterVideoUrl": "비디오 URL을 입력하세요",
19 | "enableLipSync": "립싱크 활성화",
20 | "translate": "번역",
21 | "translating": "번역 중...",
22 | "downloadVideo": "비디오 다운로드",
23 | "translateAnother": "다른 비디오 번역",
24 | "videoPreview": {
25 | "title": "비디오 미리보기",
26 | "placeholder": "위에 비디오 URL을 입력하여 미리보기",
27 | "error": "비디오를 로드할 수 없습니다. URL을 확인하세요"
28 | },
29 | "languageSelection": {
30 | "title": "언어 선택"
31 | },
32 | "basicOptions": {
33 | "title": "기본 옵션",
34 | "speakerNum": "화자 수",
35 | "autoDetect": "자동 감지",
36 | "dynamicVideoLength": "동적 비디오 길이"
37 | },
38 | "advancedSettings": {
39 | "title": "고급 설정",
40 | "removeBgm": "배경 음악 제거",
41 | "captionType": "자막",
42 | "captionFileUrl": "자막 파일 URL (SRT/ASS)",
43 | "captionFileUrlHint": "SRT 및 ASS 형식만 지원됩니다",
44 | "captionOptions": {
45 | "none": "없음",
46 | "addOriginal": "원본 언어 자막 추가",
47 | "addTarget": "대상 언어 자막 추가",
48 | "translateReplace": "원본 자막을 번역하여 교체",
49 | "addTranslated": "원본 자막을 유지하면서 번역 자막 추가"
50 | }
51 | },
52 | "loading": {
53 | "languages": "언어를 불러오는 중...",
54 | "voices": "음성을 불러오는 중..."
55 | },
56 | "processing": {
57 | "title": "비디오 처리 중",
58 | "message": "비디오를 처리 중입니다. 잠시만 기다려 주세요",
59 | "statuses": {
60 | "queueing": "대기 중",
61 | "processing": "처리 중",
62 | "completed": "완료됨",
63 | "failed": "실패"
64 | }
65 | },
66 | "results": {
67 | "title": "번역 결과",
68 | "singleVideo": "번역된 비디오가 준비되었습니다!",
69 | "multipleVideos": "번역된 비디오 {{count}}개가 준비되었습니다!",
70 | "backToGallery": "갤러리로 돌아가기",
71 | "galleryHint": "비디오를 클릭하여 미리보기 및 다운로드",
72 | "videosReady": "{{total}}개 중 {{completed}}개 비디오 준비됨",
73 | "videosReadyWithFailed": "{{completed}}개 비디오 준비됨, {{failed}}개 실패"
74 | },
75 | "errors": {
76 | "invalidInput": "유효한 비디오 URL, 원본 언어 및 대상 언어를 선택하세요",
77 | "fetchError": "언어를 가져오는 중 오류가 발생했습니다",
78 | "translationError": "비디오 번역 중 오류가 발생했습니다",
79 | "apiKeyError": "유효한 x-api-key를 입력하세요",
80 | "tokenError": "클라이언트 ID와 클라이언트 시크릿을 모두 입력하세요",
81 | "voiceRequired": "대상 언어의 음성을 선택하세요",
82 | "apiError": "API 오류"
83 | },
84 | "buttons": {
85 | "submitApiKey": "x-api-key 제출",
86 | "ok": "확인"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/video-translation-frontend/public/locales/ja/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "appTitle": "AIビデオ翻訳",
3 | "auth": {
4 | "apiKey": "x-api-key",
5 | "enterApiKey": "x-api-keyを入力してください",
6 | "clientCredentials": "クライアント認証情報",
7 | "clientId": "クライアントID",
8 | "enterClientId": "クライアントIDを入力してください",
9 | "clientSecret": "クライアントシークレット",
10 | "enterClientSecret": "クライアントシークレットを入力してください",
11 | "getToken": "トークンを取得",
12 | "fetchingToken": "トークンを取得中..."
13 | },
14 | "selectSourceLanguage": "翻訳元言語を選択",
15 | "selectTargetLanguage": "翻訳先言語を選択",
16 | "selectVoice": "音声を選択",
17 | "videoUrl": "ビデオURL",
18 | "enterVideoUrl": "ビデオURLを入力してください",
19 | "enableLipSync": "リップシンクを有効にする",
20 | "translate": "翻訳",
21 | "translating": "翻訳中...",
22 | "downloadVideo": "ビデオをダウンロード",
23 | "translateAnother": "別のビデオを翻訳",
24 | "videoPreview": {
25 | "title": "ビデオプレビュー",
26 | "placeholder": "ビデオURLを入力してプレビュー",
27 | "error": "ビデオを読み込めません。URLを確認してください"
28 | },
29 | "languageSelection": {
30 | "title": "言語選択"
31 | },
32 | "basicOptions": {
33 | "title": "基本オプション",
34 | "speakerNum": "話者数",
35 | "autoDetect": "自動検出",
36 | "dynamicVideoLength": "動画の動的長さ"
37 | },
38 | "advancedSettings": {
39 | "title": "高度な設定",
40 | "removeBgm": "背景音楽を削除",
41 | "captionType": "字幕",
42 | "captionFileUrl": "字幕ファイルURL (SRT/ASS)",
43 | "captionFileUrlHint": "SRTおよびASS形式のみサポートされています",
44 | "captionOptions": {
45 | "none": "なし",
46 | "addOriginal": "元の言語の字幕を追加",
47 | "addTarget": "ターゲット言語の字幕を追加",
48 | "translateReplace": "元の字幕を翻訳して置き換え",
49 | "addTranslated": "元の字幕を保持しながら翻訳字幕を追加"
50 | }
51 | },
52 | "loading": {
53 | "languages": "言語を読み込み中...",
54 | "voices": "音声を読み込み中..."
55 | },
56 | "processing": {
57 | "title": "ビデオ処理中",
58 | "message": "ビデオを処理中です。お待ちください",
59 | "statuses": {
60 | "queueing": "キュー待ち",
61 | "processing": "処理中",
62 | "completed": "完了",
63 | "failed": "失敗"
64 | }
65 | },
66 | "results": {
67 | "title": "翻訳結果",
68 | "singleVideo": "翻訳されたビデオの準備が整いました!",
69 | "multipleVideos": "翻訳されたビデオ {{count}} 本の準備が整いました!",
70 | "backToGallery": "ギャラリーに戻る",
71 | "galleryHint": "ビデオをクリックしてプレビューおよびダウンロード",
72 | "videosReady": "{{total}} 本中 {{completed}} 本のビデオ準備完了",
73 | "videosReadyWithFailed": "{{completed}} 本のビデオ準備完了、{{failed}} 本失敗"
74 | },
75 | "errors": {
76 | "invalidInput": "有効なビデオURL、翻訳元言語、翻訳先言語を選択してください",
77 | "fetchError": "言語の取得中にエラーが発生しました",
78 | "translationError": "ビデオの翻訳中にエラーが発生しました",
79 | "apiKeyError": "有効なx-api-keyを入力してください",
80 | "tokenError": "クライアントIDとクライアントシークレットを両方入力してください",
81 | "voiceRequired": "翻訳先言語の音声を選択してください",
82 | "apiError": "APIエラー"
83 | },
84 | "buttons": {
85 | "submitApiKey": "x-api-keyを送信",
86 | "ok": "OK"
87 | }
88 | }
--------------------------------------------------------------------------------
/video-translation-frontend/public/locales/en/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "appTitle": "AI Video Translator",
3 | "auth": {
4 | "apiKey": "x-api-key",
5 | "enterApiKey": "Enter your x-api-key",
6 | "clientCredentials": "Client Credentials",
7 | "clientId": "Client ID",
8 | "enterClientId": "Enter your Client ID",
9 | "clientSecret": "Client Secret",
10 | "enterClientSecret": "Enter your Client Secret",
11 | "getToken": "Get Token",
12 | "fetchingToken": "Fetching Token..."
13 | },
14 | "selectSourceLanguage": "Select Source Language",
15 | "selectTargetLanguage": "Select Target Language",
16 | "selectVoice": "Select Voice",
17 | "videoUrl": "Video URL",
18 | "enterVideoUrl": "Enter video URL",
19 | "enableLipSync": "Enable Lip Sync",
20 | "translate": "Translate",
21 | "translating": "Translating...",
22 | "downloadVideo": "Download Video",
23 | "translateAnother": "Translate Another",
24 | "videoPreview": {
25 | "title": "Video Preview",
26 | "placeholder": "Enter a video URL above to preview",
27 | "error": "Unable to load video. Please check the URL."
28 | },
29 | "languageSelection": {
30 | "title": "Language Selection"
31 | },
32 | "basicOptions": {
33 | "title": "Basic Options",
34 | "speakerNum": "Number of Speakers",
35 | "autoDetect": "Auto-detect",
36 | "dynamicVideoLength": "Dynamic video length"
37 | },
38 | "advancedSettings": {
39 | "title": "Advanced Settings",
40 | "removeBgm": "Remove Background Music",
41 | "captionType": "Subtitles",
42 | "captionFileUrl": "Caption File URL (SRT/ASS)",
43 | "captionFileUrlHint": "Only SRT and ASS formats are supported",
44 | "captionOptions": {
45 | "none": "None",
46 | "addOriginal": "Add captions for original language",
47 | "addTarget": "Add captions for target language",
48 | "translateReplace": "Translate and replace original subtitles",
49 | "addTranslated": "Add transferred subtitles while keeping original subtitles"
50 | }
51 | },
52 | "loading": {
53 | "languages": "Loading languages...",
54 | "voices": "Loading voices..."
55 | },
56 | "processing": {
57 | "title": "Processing Video",
58 | "message": "Your video is being processed. Thank you for your patience",
59 | "statuses": {
60 | "queueing": "Queueing",
61 | "processing": "Processing",
62 | "completed": "Completed",
63 | "failed": "Failed"
64 | }
65 | },
66 | "results": {
67 | "title": "Translation Results",
68 | "singleVideo": "Your translated video is ready!",
69 | "multipleVideos": "Your {{count}} translated videos are ready!",
70 | "backToGallery": "Back to Gallery",
71 | "galleryHint": "Click on any video to preview and download",
72 | "videosReady": "{{completed}} of {{total}} videos ready",
73 | "videosReadyWithFailed": "{{completed}} videos ready, {{failed}} failed"
74 | },
75 | "errors": {
76 | "invalidInput": "Please provide a valid video URL, select a source language and at least one target language.",
77 | "fetchError": "An error occurred while fetching languages.",
78 | "translationError": "An error occurred while translating the video.",
79 | "apiKeyError": "Please provide a valid x-api-key",
80 | "tokenError": "Please provide both Client ID and Client Secret",
81 | "voiceRequired": "Please select a voice for all target languages that require it",
82 | "apiError": "API Error"
83 | },
84 | "buttons": {
85 | "submitApiKey": "Submit x-api-key",
86 | "ok": "OK"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Akool Video Translation AI Application
2 |
3 | This project consists of a Video Translation AI application with a React frontend and Flask backend. The application allows users to translate videos to different languages along with lip sync.
4 |
5 | ## Frontend Setup
6 |
7 | ### Prerequisites
8 | - Node Version Manager (nvm)
9 | - Node.js v20
10 | - npm or yarn
11 |
12 | ### Installation & Setup
13 | 1. Install Node.js v20 using nvm:
14 | ```bash
15 | nvm install 20
16 | nvm use 20
17 | ```
18 |
19 | 2. Navigate to the frontend directory:
20 | ```bash
21 | cd video-translation-frontend
22 | ```
23 |
24 | 3. Install dependencies:
25 | ```bash
26 | npm install
27 | # or
28 | yarn install
29 | ```
30 |
31 | 4. Start the development server:
32 | ```bash
33 | npm run dev
34 | # or
35 | yarn dev
36 | ```
37 |
38 | The frontend will be available at `http://localhost:5173`
39 |
40 | ### Configuration
41 | You'll need to update the webhook URLs in `src/App.tsx`. Search for URLs containing `ngrok-free.app` and replace them with your ngrok forwarding URL. For example:
42 |
43 | Change:
44 | ```typescript
45 | webhookUrl: "https://c184-219-91-134-123.ngrok-free.app/api/webhook"
46 | ```
47 | to:
48 | ```typescript
49 | webhookUrl: "https://your-ngrok-url.ngrok-free.app/api/webhook"
50 | ```
51 |
52 | ## Backend Setup
53 |
54 | ### Prerequisites
55 | - Python 3.x
56 | - pip
57 | - Virtual environment (recommended)
58 |
59 | ### Installation & Setup
60 | 1. Create and activate a virtual environment:
61 | ```bash
62 | python -m venv venv
63 | source venv/bin/activate # On Windows: venv\Scripts\activate
64 | ```
65 |
66 | 2. Navigate to the backend directory:
67 | ```bash
68 | cd video-translation-webhook-backend
69 | ```
70 |
71 | 3. Install dependencies:
72 | ```bash
73 | pip install -r requirements.txt
74 | ```
75 |
76 | 4. Create a `.env` file in the backend directory with your credentials:
77 | ```env
78 | CLIENT_ID=your_client_id
79 | CLIENT_SECRET=your_client_secret
80 | ```
81 |
82 | 5. Start the Flask server:
83 | ```bash
84 | python app.py
85 | ```
86 |
87 | The backend will be available at `http://localhost:3007`
88 |
89 | ## Setting up ngrok
90 |
91 | ngrok is required to create a public URL for your local webhook endpoint.
92 |
93 | 1. Install ngrok:
94 | - Download from ngrok website
95 | - Sign up for a free account
96 | - Follow the installation instructions for your OS
97 |
98 | 2. Authenticate ngrok:
99 | ```bash
100 | ngrok config add-authtoken your_auth_token
101 | ```
102 |
103 | 3. Start ngrok to forward your backend port:
104 | ```bash
105 | ngrok http 3007
106 | ```
107 |
108 | Copy the forwarding URL (e.g., `https://your-ngrok-url.ngrok-free.app`) and update it in the frontend code as described in the Frontend Configuration section.
109 |
110 | ## Important Notes
111 | - Make sure both frontend and backend servers are running simultaneously
112 | - The ngrok URL changes every time you restart ngrok (unless you have a paid plan)
113 | - Update the webhook URLs in the frontend whenever you get a new ngrok URL
114 | - Keep your `CLIENT_ID` and `CLIENT_SECRET` secure and never commit them to version control
115 |
116 | ## Troubleshooting
117 | - If you encounter CORS issues, ensure the backend CORS settings are properly configured
118 | - If the websocket connection fails, check that your ports aren't blocked by a firewall
119 | - For ngrok connection issues, ensure your authtoken is properly configured
120 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/video-translation-webhook-backend/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, request, jsonify, Response
2 | from flask_cors import CORS
3 | from Crypto.Cipher import AES
4 | from dotenv import load_dotenv
5 | from flask_socketio import SocketIO, emit
6 | import base64
7 | import json
8 | import time
9 | import os
10 |
11 | load_dotenv()
12 |
13 | app = Flask(__name__)
14 | # Allow all origins with CORS
15 | CORS(app, resources={r"/*": {"origins": "*"}})
16 |
17 | # Update SocketIO configuration to allow all origins
18 | socketio = SocketIO(app,
19 | cors_allowed_origins="*",
20 | ping_timeout=60,
21 | ping_interval=25,
22 | async_mode='gevent')
23 |
24 | # Store events temporarily in memory
25 | events = []
26 |
27 | def generate_aes_decrypt(data_encrypt, client_id, client_secret):
28 | aes_key = client_secret.encode('utf-8')
29 |
30 | # Ensure the IV is 16 bytes long
31 | iv = client_id.encode('utf-8')
32 | iv = iv[:16] if len(iv) >= 16 else iv.ljust(16, b'\0')
33 |
34 | cipher = AES.new(aes_key, AES.MODE_CBC, iv)
35 | decrypted_data = cipher.decrypt(base64.b64decode(data_encrypt))
36 |
37 | # Handle padding
38 | padding_len = decrypted_data[-1]
39 | return decrypted_data[:-padding_len].decode('utf-8')
40 |
41 | @app.route('/test-app', methods=['GET'])
42 | def test_app():
43 | #emit socket event
44 | socketio.emit('message', {'data': 'Hello, World!'})
45 | return jsonify({"message": "Hello, World!"}), 200
46 |
47 | @app.route('/api/webhook', methods=['POST'])
48 | def webhook():
49 | print("Webhook received")
50 | # Log the raw request data
51 | print("Raw request data:", request.get_data())
52 | data = request.get_json()
53 | print("JSON data received:", data) # Add this line to log the incoming JSON
54 |
55 | # Extract the encrypted data and metadata
56 | encrypted_data = data.get('dataEncrypt')
57 | if not encrypted_data:
58 | print("No encrypted data found in webhook")
59 | return jsonify({"message": "Missing dataEncrypt field"}), 400
60 |
61 | client_id = os.getenv('CLIENT_ID')
62 | client_secret = os.getenv('CLIENT_SECRET')
63 |
64 | if not client_id or not client_secret:
65 | print("Missing CLIENT_ID or CLIENT_SECRET in environment variables")
66 | return jsonify({"message": "Server configuration error"}), 500
67 |
68 | try:
69 | # Decrypt the data
70 | decrypted_data = generate_aes_decrypt(encrypted_data, client_id, client_secret)
71 | print("Decrypted Data:", decrypted_data)
72 |
73 | # Process the decrypted data
74 | try:
75 | decrypted_json = json.loads(decrypted_data)
76 | print("Parsed JSON:", decrypted_json) # Add this line for debugging
77 |
78 | # Check for status field (can be 'status' or 'video_status')
79 | video_status = decrypted_json.get('status') or decrypted_json.get('video_status')
80 |
81 | if video_status is None:
82 | print("Missing 'status' or 'video_status' in payload:", decrypted_json)
83 | return jsonify({"message": "Invalid payload structure - missing status"}), 400
84 |
85 | # Log all status updates
86 | print(f"Processing status {video_status} with full payload:", decrypted_json)
87 |
88 | # Video status codes: 1: queueing, 2: processing, 3: completed, 4: failed
89 | if video_status == 3:
90 | # Video translation completed
91 | print("Video translation completed, emitting event", decrypted_json)
92 | # Ensure the payload includes url and _id for frontend
93 | event_data = {
94 | 'url': decrypted_json.get('video') or decrypted_json.get('url'),
95 | '_id': decrypted_json.get('_id') or decrypted_json.get('video_id'),
96 | 'video_status': 3,
97 | 'progress': decrypted_json.get('progress', 100)
98 | }
99 | socketio.emit('message', {'data': event_data, 'type': 'event'})
100 |
101 | elif video_status == 4:
102 | # Video translation failed
103 | print("Video translation failed")
104 | error_message = decrypted_json.get('error_reason') or decrypted_json.get('error_message') or \
105 | 'Video translation failed. This could be due to invalid video format, network issues, or processing errors. Please try again or contact support if the issue persists.'
106 |
107 | socketio.emit('message', {
108 | 'type': 'error',
109 | 'message': error_message,
110 | 'error_code': decrypted_json.get('error_code'),
111 | 'data': decrypted_json
112 | })
113 |
114 | elif video_status in [1, 2]:
115 | # Queueing or processing - emit status update with progress
116 | print(f"Video translation status {video_status} (queueing/processing), payload:", decrypted_json)
117 | status_data = {
118 | '_id': decrypted_json.get('_id') or decrypted_json.get('video_id'),
119 | 'video_status': video_status,
120 | 'progress': decrypted_json.get('progress', 0)
121 | }
122 | socketio.emit('message', {'data': status_data, 'type': 'event'})
123 | else:
124 | # Unknown status - emit as status update
125 | print(f"Received unknown status {video_status}, payload:", decrypted_json)
126 | socketio.emit('message', {'data': decrypted_json, 'type': 'status_update'})
127 |
128 | return jsonify({
129 | "message": "Webhook received and processed successfully",
130 | "decrypted_data": decrypted_json
131 | }), 200
132 |
133 | except json.JSONDecodeError as je:
134 | print(f"JSON parsing error: {je}")
135 | return jsonify({"message": "Invalid JSON format in decrypted data"}), 400
136 |
137 | except Exception as e:
138 | print(f"Error processing webhook: {e}")
139 | import traceback
140 | traceback.print_exc()
141 | return jsonify({"message": f"Error processing webhook: {str(e)}"}), 400
142 |
143 |
144 | @socketio.on('connect')
145 | def handle_connect():
146 | print("Client connected")
147 | emit('message', {'data': 'Connected to server', 'type': 'info'})
148 |
149 | @socketio.on('disconnect')
150 | def handle_disconnect():
151 | print("Client disconnected")
152 |
153 |
154 | if __name__ == '__main__':
155 | socketio.run(app, host='0.0.0.0', port=3007)
156 |
157 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/App.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #4facfe 100%);
3 | --secondary-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
4 | --accent-gradient: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
5 | --dark-bg: #0a0e27;
6 | --card-bg: rgba(255, 255, 255, 0.08);
7 | --card-border: rgba(255, 255, 255, 0.15);
8 | --text-primary: #ffffff;
9 | --text-secondary: rgba(255, 255, 255, 0.8);
10 | --success: #10b981;
11 | --error: #ef4444;
12 | --warning: #f59e0b;
13 | }
14 |
15 | * {
16 | box-sizing: border-box;
17 | }
18 |
19 | body {
20 | margin: 0;
21 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
22 | font-weight: 400;
23 | letter-spacing: -0.01em;
24 | background: linear-gradient(135deg,
25 | #251530 0%,
26 | #2a2545 16.66%,
27 | #5a6ab0 33.33%,
28 | #4d7acf 50%,
29 | #5a6ab0 66.66%,
30 | #2a2545 83.33%,
31 | #251530 100%);
32 | background-size: 400% 400%;
33 | animation: gradientShift 20s ease infinite;
34 | color: var(--text-primary);
35 | min-height: 100vh;
36 | display: flex;
37 | justify-content: center;
38 | align-items: center;
39 | padding: 20px;
40 | overflow-x: hidden;
41 | }
42 |
43 | @keyframes gradientShift {
44 | 0% { background-position: 0% 50%; }
45 | 50% { background-position: 100% 50%; }
46 | 100% { background-position: 0% 50%; }
47 | }
48 |
49 | .app {
50 | text-align: center;
51 | width: 100%;
52 | max-width: 1400px;
53 | padding: 48px 40px;
54 | background: #15151a;
55 | backdrop-filter: blur(20px);
56 | border-radius: 20px;
57 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
58 | position: relative;
59 | display: flex;
60 | flex-direction: column;
61 | align-items: center;
62 | margin: 0 auto;
63 | overflow: visible;
64 | }
65 |
66 | .app::before {
67 | content: '';
68 | position: absolute;
69 | top: 0;
70 | left: 0;
71 | right: 0;
72 | bottom: 0;
73 | border-radius: 24px;
74 | padding: 2px;
75 | background: var(--primary-gradient);
76 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
77 | -webkit-mask-composite: xor;
78 | mask-composite: exclude;
79 | pointer-events: none;
80 | opacity: 0.5;
81 | }
82 |
83 | .app-header {
84 | width: 100%;
85 | margin-bottom: 20px;
86 | position: relative;
87 | }
88 |
89 | .app-header .logo {
90 | display: flex;
91 | align-items: center;
92 | justify-content: center;
93 | margin-bottom: 12px;
94 | gap: 15px;
95 | }
96 |
97 | .app-header .logo img {
98 | width: 180px;
99 | height: auto;
100 | filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
101 | }
102 |
103 | .app-header h1 {
104 | font-size: 2.25rem;
105 | margin: 0;
106 | font-weight: 600;
107 | color: #ffffff;
108 | letter-spacing: -0.02em;
109 | line-height: 1.2;
110 | }
111 |
112 | /* Language Selector */
113 | .language-selector-container {
114 | position: fixed;
115 | top: 1.5rem;
116 | right: 1.5rem;
117 | z-index: 1000;
118 | }
119 |
120 | .language-selector-container select {
121 | padding: 10px 40px 10px 16px;
122 | border-radius: 12px;
123 | border: 1px solid rgba(255, 255, 255, 0.12);
124 | background: rgba(21, 21, 26, 0.8);
125 | backdrop-filter: blur(10px);
126 | color: white;
127 | font-size: 0.875rem;
128 | font-weight: 500;
129 | letter-spacing: 0.01em;
130 | cursor: pointer;
131 | transition: all 0.2s ease;
132 | appearance: none;
133 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
134 | background-repeat: no-repeat;
135 | background-position: right 10px center;
136 | background-size: 18px;
137 | min-width: 140px;
138 | }
139 |
140 | .language-selector-container select:hover {
141 | border-color: rgba(255, 255, 255, 0.2);
142 | background: rgba(21, 21, 26, 0.95);
143 | }
144 |
145 | /* Auth Container */
146 | .auth-container {
147 | width: 100%;
148 | max-width: 550px;
149 | margin: 0 auto;
150 | text-align: center;
151 | animation: fadeIn 0.4s ease-out;
152 | display: flex;
153 | flex-direction: column;
154 | align-items: center;
155 | justify-content: center;
156 | padding: 48px 40px;
157 | }
158 |
159 | .auth-tabs {
160 | display: flex;
161 | margin: 0 auto 32px auto;
162 | background: rgba(255, 255, 255, 0.04);
163 | border-radius: 12px;
164 | padding: 6px;
165 | width: 100%;
166 | align-items: center;
167 | justify-content: center;
168 | gap: 4px;
169 | border: 1px solid rgba(255, 255, 255, 0.08);
170 | }
171 |
172 | .auth-tab {
173 | flex: 1;
174 | padding: 12px 24px;
175 | cursor: pointer;
176 | border: none;
177 | background: transparent;
178 | color: rgba(255, 255, 255, 0.6);
179 | font-size: 0.9375rem;
180 | font-weight: 500;
181 | letter-spacing: 0.01em;
182 | transition: all 0.2s ease;
183 | border-radius: 8px;
184 | text-align: center;
185 | position: relative;
186 | min-width: 0;
187 | white-space: nowrap;
188 | display: flex;
189 | align-items: center;
190 | justify-content: center;
191 | }
192 |
193 | .auth-tab:hover:not(.active) {
194 | background: rgba(255, 255, 255, 0.04);
195 | color: rgba(255, 255, 255, 0.8);
196 | }
197 |
198 | .auth-tab.active {
199 | background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
200 | color: white;
201 | box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
202 | font-weight: 600;
203 | }
204 |
205 | .auth-content {
206 | padding: 40px;
207 | background: rgba(255, 255, 255, 0.03);
208 | border-radius: 16px;
209 | margin-top: 0;
210 | display: flex;
211 | flex-direction: column;
212 | align-items: center;
213 | justify-content: center;
214 | width: 100%;
215 | border: 1px solid rgba(255, 255, 255, 0.08);
216 | backdrop-filter: blur(10px);
217 | }
218 |
219 | .api-key-form,
220 | .credentials-form {
221 | width: 100%;
222 | display: flex;
223 | flex-direction: column;
224 | align-items: center;
225 | justify-content: center;
226 | gap: 0;
227 | }
228 |
229 | .input-group {
230 | margin-bottom: 28px;
231 | text-align: left;
232 | width: 100%;
233 | }
234 |
235 | .input-group label {
236 | display: block;
237 | margin-bottom: 10px;
238 | color: rgba(255, 255, 255, 0.9);
239 | font-size: 0.875rem;
240 | font-weight: 500;
241 | letter-spacing: 0.01em;
242 | }
243 |
244 | .input-group input {
245 | width: 100%;
246 | padding: 14px 18px;
247 | border-radius: 10px;
248 | border: 1px solid rgba(255, 255, 255, 0.12);
249 | background: rgba(255, 255, 255, 0.05);
250 | color: white;
251 | font-size: 0.9375rem;
252 | font-weight: 400;
253 | letter-spacing: 0.01em;
254 | transition: all 0.2s ease;
255 | }
256 |
257 | .input-group input:focus {
258 | outline: none;
259 | border-color: #2563eb;
260 | background: rgba(255, 255, 255, 0.08);
261 | box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
262 | }
263 |
264 | .input-group input::placeholder {
265 | color: rgba(255, 255, 255, 0.4);
266 | }
267 |
268 | .auth-button {
269 | width: 100%;
270 | max-width: 240px;
271 | padding: 16px 32px;
272 | background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
273 | color: white;
274 | border: none;
275 | border-radius: 12px;
276 | cursor: pointer;
277 | font-size: 1.1rem;
278 | font-weight: 700;
279 | margin-top: 10px;
280 | transition: all 0.3s ease;
281 | box-shadow: 0 6px 20px rgba(37, 99, 235, 0.4);
282 | position: relative;
283 | overflow: hidden;
284 | }
285 |
286 | .auth-button::before {
287 | content: '';
288 | position: absolute;
289 | top: 50%;
290 | left: 50%;
291 | width: 0;
292 | height: 0;
293 | border-radius: 50%;
294 | background: rgba(255, 255, 255, 0.3);
295 | transform: translate(-50%, -50%);
296 | transition: width 0.6s, height 0.6s;
297 | }
298 |
299 | .auth-button:hover::before {
300 | width: 400px;
301 | height: 400px;
302 | }
303 |
304 | .auth-button:hover {
305 | transform: translateY(-3px);
306 | box-shadow: 0 10px 30px rgba(37, 99, 235, 0.5);
307 | }
308 |
309 | .auth-button:active {
310 | transform: translateY(-1px);
311 | }
312 |
313 | .auth-button:disabled {
314 | opacity: 0.5;
315 | cursor: not-allowed;
316 | transform: none;
317 | }
318 |
319 | .spinner {
320 | width: 20px;
321 | height: 20px;
322 | border: 3px solid rgba(255, 255, 255, 0.3);
323 | border-top: 3px solid white;
324 | border-radius: 50%;
325 | animation: spin 1s linear infinite;
326 | display: inline-block;
327 | margin-right: 10px;
328 | }
329 |
330 | @keyframes spin {
331 | 0% { transform: rotate(0deg); }
332 | 100% { transform: rotate(360deg); }
333 | }
334 |
335 | .error-message {
336 | margin-top: 20px;
337 | color: var(--error);
338 | display: flex;
339 | align-items: center;
340 | justify-content: center;
341 | gap: 10px;
342 | font-size: 0.95rem;
343 | width: 100%;
344 | text-align: center;
345 | padding: 12px 20px;
346 | background: rgba(239, 68, 68, 0.1);
347 | border-radius: 12px;
348 | border: 1px solid rgba(239, 68, 68, 0.3);
349 | }
350 |
351 | /* Main Content */
352 | .main-content {
353 | width: 100%;
354 | max-width: 100%;
355 | margin: 0 auto;
356 | padding: 0;
357 | }
358 |
359 | .translation-workspace {
360 | display: flex;
361 | flex-direction: column;
362 | gap: 16px;
363 | width: 100%;
364 | }
365 |
366 | .translation-workspace-horizontal {
367 | display: grid;
368 | grid-template-columns: 1fr 1fr;
369 | gap: 20px;
370 | width: 100%;
371 | align-items: start;
372 | }
373 |
374 | .inputs-section {
375 | display: flex;
376 | flex-direction: column;
377 | gap: 16px;
378 | max-height: calc(100vh - 250px);
379 | overflow-y: auto;
380 | overflow-x: hidden;
381 | padding-right: 10px;
382 | padding-bottom: 20px;
383 | scroll-behavior: smooth;
384 | }
385 |
386 | .inputs-section::-webkit-scrollbar {
387 | width: 10px;
388 | }
389 |
390 | .inputs-section::-webkit-scrollbar-track {
391 | background: rgba(255, 255, 255, 0.08);
392 | border-radius: 10px;
393 | margin: 4px 0;
394 | }
395 |
396 | .inputs-section::-webkit-scrollbar-thumb {
397 | background: rgba(255, 255, 255, 0.3);
398 | border-radius: 10px;
399 | border: 2px solid rgba(255, 255, 255, 0.05);
400 | }
401 |
402 | .inputs-section::-webkit-scrollbar-thumb:hover {
403 | background: rgba(255, 255, 255, 0.4);
404 | }
405 |
406 | .video-section {
407 | position: sticky;
408 | top: 10px;
409 | max-height: calc(100vh - 180px);
410 | overflow-y: auto;
411 | }
412 |
413 | .video-section::-webkit-scrollbar {
414 | width: 8px;
415 | }
416 |
417 | .video-section::-webkit-scrollbar-track {
418 | background: rgba(255, 255, 255, 0.05);
419 | border-radius: 10px;
420 | }
421 |
422 | .video-section::-webkit-scrollbar-thumb {
423 | background: rgba(255, 255, 255, 0.2);
424 | border-radius: 10px;
425 | }
426 |
427 | .video-section::-webkit-scrollbar-thumb:hover {
428 | background: rgba(255, 255, 255, 0.3);
429 | }
430 |
431 | /* Video Preview Section */
432 | .video-preview-section {
433 | background: rgba(255, 255, 255, 0.03);
434 | border-radius: 16px;
435 | padding: 20px;
436 | border: 1px solid rgba(255, 255, 255, 0.08);
437 | backdrop-filter: blur(10px);
438 | animation: slideInDown 0.4s ease-out;
439 | }
440 |
441 | @keyframes slideInDown {
442 | from {
443 | opacity: 0;
444 | transform: translateY(-30px);
445 | }
446 | to {
447 | opacity: 1;
448 | transform: translateY(0);
449 | }
450 | }
451 |
452 | .section-header {
453 | display: flex;
454 | align-items: center;
455 | gap: 12px;
456 | margin-bottom: 12px;
457 | }
458 |
459 | .section-header h2 {
460 | font-size: 1.25rem;
461 | font-weight: 600;
462 | margin: 0;
463 | color: #ffffff;
464 | letter-spacing: -0.01em;
465 | }
466 |
467 | .section-header .icon {
468 | font-size: 1.8rem;
469 | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
470 | }
471 |
472 | .video-preview-container {
473 | width: 100%;
474 | border-radius: 16px;
475 | overflow: hidden;
476 | background: rgba(0, 0, 0, 0.4);
477 | min-height: 200px;
478 | max-height: calc(100vh - 280px);
479 | display: flex;
480 | align-items: center;
481 | justify-content: center;
482 | position: relative;
483 | }
484 |
485 | .source-video-preview {
486 | width: 100%;
487 | max-height: calc(100vh - 280px);
488 | border-radius: 16px;
489 | background: #000;
490 | }
491 |
492 | .video-placeholder {
493 | display: flex;
494 | flex-direction: column;
495 | align-items: center;
496 | justify-content: center;
497 | padding: 40px 20px;
498 | color: rgba(255, 255, 255, 0.5);
499 | min-height: 200px;
500 | }
501 |
502 | .placeholder-icon {
503 | font-size: 4rem;
504 | margin-bottom: 20px;
505 | opacity: 0.6;
506 | animation: pulse 2s ease-in-out infinite;
507 | }
508 |
509 | .video-preview-error {
510 | position: absolute;
511 | bottom: 20px;
512 | left: 50%;
513 | transform: translateX(-50%);
514 | background: rgba(239, 68, 68, 0.9);
515 | color: white;
516 | padding: 12px 24px;
517 | border-radius: 8px;
518 | font-size: 0.9rem;
519 | }
520 |
521 | /* Input Card */
522 | .input-card {
523 | background: rgba(255, 255, 255, 0.03);
524 | border-radius: 16px;
525 | padding: 20px;
526 | border: 1px solid rgba(255, 255, 255, 0.08);
527 | backdrop-filter: blur(10px);
528 | animation: slideInUp 0.4s ease-out 0.1s both;
529 | flex-shrink: 0;
530 | }
531 |
532 | @keyframes slideInUp {
533 | from {
534 | opacity: 0;
535 | transform: translateY(30px);
536 | }
537 | to {
538 | opacity: 1;
539 | transform: translateY(0);
540 | }
541 | }
542 |
543 | .card-header {
544 | display: flex;
545 | align-items: center;
546 | gap: 12px;
547 | margin-bottom: 18px;
548 | }
549 |
550 | .card-icon {
551 | font-size: 1.5rem;
552 | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
553 | }
554 |
555 | .card-header label,
556 | .card-header h3 {
557 | font-size: 1.125rem;
558 | font-weight: 600;
559 | margin: 0;
560 | color: var(--text-primary);
561 | letter-spacing: -0.01em;
562 | }
563 |
564 | .modern-input {
565 | width: 100%;
566 | padding: 14px 18px;
567 | border-radius: 10px;
568 | border: 1px solid rgba(255, 255, 255, 0.12);
569 | background: rgba(255, 255, 255, 0.05);
570 | color: white;
571 | font-size: 0.9375rem;
572 | font-weight: 400;
573 | letter-spacing: 0.01em;
574 | transition: all 0.2s ease;
575 | }
576 |
577 | .modern-input:focus {
578 | outline: none;
579 | border-color: #2563eb;
580 | background: rgba(255, 255, 255, 0.08);
581 | box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
582 | }
583 |
584 | .modern-input::placeholder {
585 | color: rgba(255, 255, 255, 0.4);
586 | }
587 |
588 | /* Language Selection Card */
589 | .language-selection-card {
590 | background: rgba(255, 255, 255, 0.03);
591 | border-radius: 16px;
592 | padding: 20px;
593 | border: 1px solid rgba(255, 255, 255, 0.08);
594 | backdrop-filter: blur(10px);
595 | animation: slideInUp 0.4s ease-out 0.2s both;
596 | flex-shrink: 0;
597 | }
598 |
599 | .language-selector-grid {
600 | display: grid;
601 | grid-template-columns: 1fr auto 1fr;
602 | gap: 12px;
603 | align-items: end;
604 | }
605 |
606 | .select-card {
607 | display: flex;
608 | flex-direction: column;
609 | gap: 12px;
610 | }
611 |
612 | .select-label {
613 | display: flex;
614 | align-items: center;
615 | gap: 8px;
616 | font-size: 0.875rem;
617 | font-weight: 500;
618 | letter-spacing: 0.01em;
619 | color: rgba(255, 255, 255, 0.8);
620 | margin-bottom: 8px;
621 | }
622 |
623 | .label-icon {
624 | font-size: 1.2rem;
625 | }
626 |
627 | .arrow-icon {
628 | font-size: 1.5rem;
629 | margin-bottom: 8px;
630 | color: rgba(255, 255, 255, 0.4);
631 | font-weight: 300;
632 | }
633 |
634 |
635 | .modern-select {
636 | width: 100%;
637 | padding: 14px 18px;
638 | border-radius: 10px;
639 | border: 1px solid rgba(255, 255, 255, 0.12);
640 | background: rgba(255, 255, 255, 0.05);
641 | color: white;
642 | font-size: 0.9375rem;
643 | font-weight: 400;
644 | letter-spacing: 0.01em;
645 | cursor: pointer;
646 | transition: all 0.2s ease;
647 | appearance: none;
648 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
649 | background-repeat: no-repeat;
650 | background-position: right 15px center;
651 | background-size: 18px;
652 | padding-right: 48px;
653 | }
654 |
655 | .modern-select:hover {
656 | border-color: rgba(255, 255, 255, 0.18);
657 | background: rgba(255, 255, 255, 0.07);
658 | }
659 |
660 | .modern-select:focus {
661 | outline: none;
662 | border-color: #2563eb;
663 | background: rgba(255, 255, 255, 0.08);
664 | box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
665 | }
666 |
667 | .modern-select option {
668 | background: #15151a;
669 | color: rgba(255, 255, 255, 0.9);
670 | padding: 10px 16px;
671 | font-size: 0.9375rem;
672 | line-height: 1.6;
673 | }
674 |
675 | .modern-select option:hover,
676 | .modern-select option:checked,
677 | .modern-select option:focus {
678 | background: rgba(102, 126, 234, 0.2);
679 | color: #fff;
680 | }
681 |
682 | /* Multi-select with tags */
683 | .multi-select-container {
684 | width: 100%;
685 | }
686 |
687 | .multi-select-tags {
688 | display: flex;
689 | flex-wrap: wrap;
690 | gap: 8px;
691 | padding: 14px 18px;
692 | min-height: 48px;
693 | border-radius: 10px;
694 | border: 1px solid rgba(255, 255, 255, 0.12);
695 | background: rgba(255, 255, 255, 0.05);
696 | align-items: center;
697 | cursor: text;
698 | }
699 |
700 | .multi-select-tags:focus-within {
701 | border-color: #2563eb;
702 | background: rgba(255, 255, 255, 0.08);
703 | box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
704 | }
705 |
706 | .language-tag {
707 | display: inline-flex;
708 | align-items: center;
709 | gap: 6px;
710 | padding: 6px 12px;
711 | background: rgba(255, 255, 255, 0.1);
712 | border: 1px solid rgba(255, 255, 255, 0.15);
713 | border-radius: 6px;
714 | font-size: 0.875rem;
715 | color: white;
716 | white-space: nowrap;
717 | }
718 |
719 | .tag-remove {
720 | background: rgba(255, 255, 255, 0.2);
721 | border: none;
722 | border-radius: 50%;
723 | width: 18px;
724 | height: 18px;
725 | display: flex;
726 | align-items: center;
727 | justify-content: center;
728 | cursor: pointer;
729 | color: white;
730 | font-size: 16px;
731 | line-height: 1;
732 | padding: 0;
733 | transition: all 0.2s ease;
734 | flex-shrink: 0;
735 | }
736 |
737 | .tag-remove:hover {
738 | background: rgba(255, 255, 255, 0.3);
739 | transform: scale(1.1);
740 | }
741 |
742 | .multi-select-dropdown {
743 | flex: 1;
744 | min-width: 150px;
745 | border: 1px solid rgba(255, 255, 255, 0.12);
746 | background: rgba(255, 255, 255, 0.05);
747 | color: rgba(255, 255, 255, 0.9);
748 | font-size: 0.9375rem;
749 | font-weight: 400;
750 | letter-spacing: 0.01em;
751 | cursor: pointer;
752 | outline: none;
753 | padding: 10px 32px 10px 12px;
754 | appearance: none;
755 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
756 | background-repeat: no-repeat;
757 | background-position: right 10px center;
758 | background-size: 18px;
759 | border-radius: 8px;
760 | transition: all 0.2s ease;
761 | }
762 |
763 | .multi-select-dropdown:invalid,
764 | .multi-select-dropdown option[value=""] {
765 | color: rgba(255, 255, 255, 0.6);
766 | }
767 |
768 | .multi-select-dropdown:hover {
769 | border-color: rgba(255, 255, 255, 0.18);
770 | background: rgba(255, 255, 255, 0.07);
771 | }
772 |
773 | .multi-select-dropdown:focus {
774 | border-color: rgba(102, 126, 234, 0.5);
775 | background: rgba(255, 255, 255, 0.08);
776 | box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
777 | color: rgba(255, 255, 255, 0.9);
778 | }
779 |
780 | .multi-select-dropdown option {
781 | background: #15151a;
782 | color: rgba(255, 255, 255, 0.9);
783 | padding: 10px 16px;
784 | font-size: 0.9375rem;
785 | line-height: 1.6;
786 | }
787 |
788 | .multi-select-dropdown option[value=""] {
789 | color: rgba(255, 255, 255, 0.5);
790 | font-style: italic;
791 | }
792 |
793 | .multi-select-dropdown option:hover,
794 | .multi-select-dropdown option:checked,
795 | .multi-select-dropdown option:focus {
796 | background: rgba(102, 126, 234, 0.2);
797 | color: #fff;
798 | }
799 |
800 | .multi-select-dropdown option.section-header {
801 | font-weight: 700;
802 | font-size: 0.9375rem;
803 | background: #15151a;
804 | color: rgba(255, 255, 255, 0.9);
805 | padding: 10px 16px;
806 | cursor: default;
807 | }
808 |
809 | /* Voice Selection Card */
810 | .voice-selection-card {
811 | background: rgba(255, 255, 255, 0.03);
812 | border-radius: 16px;
813 | padding: 20px;
814 | border: 1px solid rgba(255, 255, 255, 0.08);
815 | backdrop-filter: blur(10px);
816 | animation: slideInUp 0.4s ease-out 0.3s both;
817 | flex-shrink: 0;
818 | }
819 |
820 | .voice-grid {
821 | display: grid;
822 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
823 | gap: 12px;
824 | max-height: 350px;
825 | overflow-y: auto;
826 | padding: 8px;
827 | border-radius: 12px;
828 | background: rgba(0, 0, 0, 0.2);
829 | }
830 |
831 | .voice-grid::-webkit-scrollbar {
832 | width: 10px;
833 | }
834 |
835 | .voice-grid::-webkit-scrollbar-track {
836 | background: rgba(255, 255, 255, 0.1);
837 | border-radius: 10px;
838 | }
839 |
840 | .voice-grid::-webkit-scrollbar-thumb {
841 | background: var(--primary-gradient);
842 | border-radius: 10px;
843 | }
844 |
845 | .voice-grid::-webkit-scrollbar-thumb:hover {
846 | background: var(--secondary-gradient);
847 | }
848 |
849 | .voice-card {
850 | background: rgba(255, 255, 255, 0.04);
851 | border: 1px solid rgba(255, 255, 255, 0.1);
852 | border-radius: 12px;
853 | padding: 12px;
854 | cursor: pointer;
855 | transition: all 0.2s ease;
856 | display: flex;
857 | flex-direction: column;
858 | align-items: center;
859 | gap: 8px;
860 | position: relative;
861 | overflow: hidden;
862 | }
863 |
864 | .voice-card::before {
865 | content: '';
866 | position: absolute;
867 | top: 0;
868 | left: -100%;
869 | width: 100%;
870 | height: 100%;
871 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
872 | transition: left 0.5s;
873 | }
874 |
875 | .voice-card:hover::before {
876 | left: 100%;
877 | }
878 |
879 | .voice-card:hover {
880 | transform: translateY(-2px);
881 | border-color: #2563eb;
882 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
883 | background: rgba(255, 255, 255, 0.06);
884 | }
885 |
886 | .voice-card.selected {
887 | border-color: #2563eb;
888 | background: rgba(37, 99, 235, 0.15);
889 | box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.3);
890 | }
891 |
892 | .voice-card.selected::after {
893 | content: '✓';
894 | position: absolute;
895 | top: 12px;
896 | right: 12px;
897 | background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
898 | color: white;
899 | width: 24px;
900 | height: 24px;
901 | border-radius: 50%;
902 | display: flex;
903 | align-items: center;
904 | justify-content: center;
905 | font-weight: 600;
906 | font-size: 14px;
907 | box-shadow: 0 2px 6px rgba(37, 99, 235, 0.4);
908 | }
909 |
910 | .voice-thumbnail {
911 | width: 70px;
912 | height: 70px;
913 | border-radius: 50%;
914 | object-fit: cover;
915 | border: 3px solid rgba(255, 255, 255, 0.2);
916 | transition: all 0.3s ease;
917 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
918 | }
919 |
920 | .voice-card:hover .voice-thumbnail {
921 | border-color: #2563eb;
922 | transform: scale(1.05);
923 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
924 | }
925 |
926 | .voice-info {
927 | text-align: center;
928 | width: 100%;
929 | }
930 |
931 | .voice-info h4 {
932 | margin: 0 0 4px 0;
933 | color: white;
934 | font-size: 0.875rem;
935 | font-weight: 600;
936 | letter-spacing: 0.01em;
937 | }
938 |
939 | .voice-gender {
940 | margin: 0 0 6px 0;
941 | color: rgba(255, 255, 255, 0.6);
942 | font-size: 0.75rem;
943 | font-weight: 400;
944 | letter-spacing: 0.01em;
945 | }
946 |
947 | .voice-preview {
948 | width: 100%;
949 | height: 28px;
950 | margin-top: 4px;
951 | border-radius: 6px;
952 | }
953 |
954 | .voice-preview::-webkit-media-controls-panel {
955 | background-color: rgba(102, 126, 234, 0.3);
956 | border-radius: 8px;
957 | }
958 |
959 | /* Options Card */
960 | .options-card {
961 | background: rgba(255, 255, 255, 0.03);
962 | border-radius: 16px;
963 | padding: 20px;
964 | border: 1px solid rgba(255, 255, 255, 0.08);
965 | backdrop-filter: blur(10px);
966 | animation: slideInUp 0.4s ease-out 0.4s both;
967 | flex-shrink: 0;
968 | }
969 |
970 | .options-grid {
971 | display: flex;
972 | flex-direction: column;
973 | gap: 12px;
974 | }
975 |
976 | .option-select {
977 | display: flex;
978 | flex-direction: column;
979 | gap: 8px;
980 | }
981 |
982 | .option-toggle {
983 | background: rgba(255, 255, 255, 0.03);
984 | border-radius: 10px;
985 | padding: 14px;
986 | border: 1px solid rgba(255, 255, 255, 0.08);
987 | transition: all 0.2s ease;
988 | }
989 |
990 | .option-toggle:hover {
991 | background: rgba(255, 255, 255, 0.05);
992 | border-color: rgba(255, 255, 255, 0.12);
993 | }
994 |
995 | .toggle-label {
996 | display: flex;
997 | align-items: center;
998 | gap: 15px;
999 | cursor: pointer;
1000 | position: relative;
1001 | }
1002 |
1003 | .toggle-checkbox {
1004 | position: absolute;
1005 | opacity: 0;
1006 | width: 0;
1007 | height: 0;
1008 | }
1009 |
1010 | .toggle-slider {
1011 | position: relative;
1012 | width: 56px;
1013 | height: 32px;
1014 | background: rgba(255, 255, 255, 0.2);
1015 | border-radius: 16px;
1016 | transition: all 0.3s ease;
1017 | flex-shrink: 0;
1018 | }
1019 |
1020 | .toggle-slider::before {
1021 | content: '';
1022 | position: absolute;
1023 | width: 24px;
1024 | height: 24px;
1025 | border-radius: 50%;
1026 | background: white;
1027 | top: 4px;
1028 | left: 4px;
1029 | transition: all 0.3s ease;
1030 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
1031 | }
1032 |
1033 | .toggle-checkbox:checked + .toggle-slider {
1034 | background: var(--primary-gradient);
1035 | }
1036 |
1037 | .toggle-checkbox:checked + .toggle-slider::before {
1038 | transform: translateX(24px);
1039 | }
1040 |
1041 | .toggle-text {
1042 | display: flex;
1043 | align-items: center;
1044 | gap: 10px;
1045 | font-size: 0.9375rem;
1046 | font-weight: 500;
1047 | letter-spacing: 0.01em;
1048 | color: rgba(255, 255, 255, 0.9);
1049 | }
1050 |
1051 | .toggle-icon {
1052 | font-size: 1.3rem;
1053 | }
1054 |
1055 | /* Advanced Settings */
1056 | .advanced-settings-card {
1057 | background: rgba(255, 255, 255, 0.03);
1058 | border-radius: 16px;
1059 | border: 1px solid rgba(255, 255, 255, 0.08);
1060 | backdrop-filter: blur(10px);
1061 | overflow: visible;
1062 | animation: slideInUp 0.4s ease-out 0.5s both;
1063 | flex-shrink: 0;
1064 | }
1065 |
1066 | .advanced-toggle {
1067 | width: 100%;
1068 | padding: 16px 20px;
1069 | background: transparent;
1070 | border: none;
1071 | color: var(--text-primary);
1072 | font-size: 1.1rem;
1073 | font-weight: 700;
1074 | cursor: pointer;
1075 | display: flex;
1076 | align-items: center;
1077 | gap: 15px;
1078 | transition: all 0.3s ease;
1079 | text-align: left;
1080 | }
1081 |
1082 | .advanced-toggle:hover {
1083 | background: rgba(255, 255, 255, 0.05);
1084 | }
1085 |
1086 | .toggle-arrow {
1087 | margin-left: auto;
1088 | transition: transform 0.3s ease;
1089 | font-size: 1rem;
1090 | }
1091 |
1092 | .toggle-arrow.open {
1093 | transform: rotate(180deg);
1094 | }
1095 |
1096 | .advanced-content {
1097 | padding: 0 20px 20px 20px;
1098 | animation: slideDown 0.3s ease-out;
1099 | }
1100 |
1101 | @keyframes slideDown {
1102 | from {
1103 | opacity: 0;
1104 | max-height: 0;
1105 | }
1106 | to {
1107 | opacity: 1;
1108 | max-height: 500px;
1109 | }
1110 | }
1111 |
1112 | .advanced-option {
1113 | background: rgba(255, 255, 255, 0.05);
1114 | border-radius: 12px;
1115 | padding: 16px;
1116 | border: 1px solid rgba(255, 255, 255, 0.1);
1117 | }
1118 |
1119 | .slider-label {
1120 | display: flex;
1121 | justify-content: space-between;
1122 | align-items: center;
1123 | margin-bottom: 15px;
1124 | }
1125 |
1126 | .label-text {
1127 | display: flex;
1128 | align-items: center;
1129 | gap: 10px;
1130 | font-size: 1.1rem;
1131 | font-weight: 600;
1132 | color: var(--text-primary);
1133 | }
1134 |
1135 | .slider-value {
1136 | background: var(--primary-gradient);
1137 | color: white;
1138 | padding: 6px 16px;
1139 | border-radius: 20px;
1140 | font-weight: 700;
1141 | font-size: 1.1rem;
1142 | min-width: 50px;
1143 | text-align: center;
1144 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
1145 | }
1146 |
1147 | .slider-input {
1148 | width: 100%;
1149 | height: 8px;
1150 | border-radius: 4px;
1151 | background: rgba(255, 255, 255, 0.2);
1152 | outline: none;
1153 | -webkit-appearance: none;
1154 | appearance: none;
1155 | margin: 20px 0;
1156 | }
1157 |
1158 | .slider-input::-webkit-slider-thumb {
1159 | -webkit-appearance: none;
1160 | appearance: none;
1161 | width: 24px;
1162 | height: 24px;
1163 | border-radius: 50%;
1164 | background: var(--primary-gradient);
1165 | cursor: pointer;
1166 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
1167 | transition: all 0.3s ease;
1168 | }
1169 |
1170 | .slider-input::-webkit-slider-thumb:hover {
1171 | transform: scale(1.2);
1172 | box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
1173 | }
1174 |
1175 | .slider-input::-moz-range-thumb {
1176 | width: 24px;
1177 | height: 24px;
1178 | border-radius: 50%;
1179 | background: var(--primary-gradient);
1180 | cursor: pointer;
1181 | border: none;
1182 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
1183 | }
1184 |
1185 | .slider-labels {
1186 | display: flex;
1187 | justify-content: space-between;
1188 | font-size: 0.9rem;
1189 | color: rgba(255, 255, 255, 0.6);
1190 | margin-top: 10px;
1191 | }
1192 |
1193 | /* Action Section */
1194 | .action-section {
1195 | display: flex;
1196 | justify-content: center;
1197 | width: 100%;
1198 | margin-top: 8px;
1199 | margin-bottom: 8px;
1200 | animation: slideInUp 0.6s ease-out 0.6s both;
1201 | flex-shrink: 0;
1202 | }
1203 |
1204 | .translate-btn-primary {
1205 | background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
1206 | color: white;
1207 | border: none;
1208 | padding: 16px 40px;
1209 | border-radius: 10px;
1210 | font-size: 1rem;
1211 | font-weight: 600;
1212 | letter-spacing: 0.02em;
1213 | cursor: pointer;
1214 | transition: all 0.2s ease;
1215 | box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
1216 | position: relative;
1217 | overflow: hidden;
1218 | display: flex;
1219 | align-items: center;
1220 | gap: 10px;
1221 | }
1222 |
1223 | .translate-btn-primary::before {
1224 | content: '';
1225 | position: absolute;
1226 | top: 50%;
1227 | left: 50%;
1228 | width: 0;
1229 | height: 0;
1230 | border-radius: 50%;
1231 | background: rgba(255, 255, 255, 0.3);
1232 | transform: translate(-50%, -50%);
1233 | transition: width 0.6s, height 0.6s;
1234 | }
1235 |
1236 | .translate-btn-primary:hover::before {
1237 | width: 500px;
1238 | height: 500px;
1239 | }
1240 |
1241 | .translate-btn-primary:hover {
1242 | transform: translateY(-1px);
1243 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
1244 | }
1245 |
1246 | .translate-btn-primary:active {
1247 | transform: translateY(-2px) scale(1.02);
1248 | }
1249 |
1250 | .translate-btn-primary:disabled {
1251 | opacity: 0.5;
1252 | cursor: not-allowed;
1253 | transform: none;
1254 | }
1255 |
1256 | .btn-icon {
1257 | font-size: 1.5rem;
1258 | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
1259 | }
1260 |
1261 | /* Loader */
1262 | .loader-container {
1263 | display: flex;
1264 | flex-direction: column;
1265 | align-items: center;
1266 | justify-content: center;
1267 | gap: 25px;
1268 | padding: 60px 40px;
1269 | }
1270 |
1271 | .loader-container p {
1272 | color: white;
1273 | font-size: 1.2rem;
1274 | font-weight: 600;
1275 | margin: 0;
1276 | }
1277 |
1278 | .loader {
1279 | width: 64px;
1280 | height: 64px;
1281 | border-radius: 50%;
1282 | position: relative;
1283 | margin: 0 auto 24px;
1284 | z-index: 1;
1285 | border: 4px solid rgba(255, 255, 255, 0.1);
1286 | border-top: 4px solid #667eea;
1287 | border-right: 4px solid #764ba2;
1288 | animation: spin 1s linear infinite;
1289 | box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
1290 | }
1291 |
1292 | .loader::after {
1293 | content: '';
1294 | position: absolute;
1295 | top: -4px;
1296 | left: -4px;
1297 | right: -4px;
1298 | bottom: -4px;
1299 | border-radius: 50%;
1300 | border: 4px solid transparent;
1301 | border-bottom: 4px solid #4facfe;
1302 | border-left: 4px solid #00f2fe;
1303 | animation: spin 0.8s linear infinite reverse;
1304 | opacity: 0.6;
1305 | }
1306 |
1307 | .loader-container-small {
1308 | display: flex;
1309 | flex-direction: column;
1310 | align-items: center;
1311 | gap: 12px;
1312 | padding: 20px;
1313 | }
1314 |
1315 | .loader-small {
1316 | border: 3px solid rgba(255, 255, 255, 0.2);
1317 | border-top: 3px solid #667eea;
1318 | border-radius: 50%;
1319 | width: 40px;
1320 | height: 40px;
1321 | animation: spin 1s linear infinite;
1322 | }
1323 |
1324 | .spinner-small {
1325 | width: 18px;
1326 | height: 18px;
1327 | border: 2px solid rgba(255, 255, 255, 0.3);
1328 | border-top: 2px solid white;
1329 | border-radius: 50%;
1330 | animation: spin 0.8s linear infinite;
1331 | display: inline-block;
1332 | margin-right: 10px;
1333 | }
1334 |
1335 | /* Result Container */
1336 | .result-container {
1337 | width: 100%;
1338 | max-width: 100%;
1339 | margin: 0 auto;
1340 | padding: 40px 30px;
1341 | animation: fadeIn 0.6s ease-out;
1342 | }
1343 |
1344 | .results-header {
1345 | text-align: center;
1346 | margin-bottom: 40px;
1347 | animation: slideDown 0.6s ease-out;
1348 | }
1349 |
1350 | .results-title {
1351 | font-size: 2.5rem;
1352 | font-weight: 700;
1353 | margin-bottom: 12px;
1354 | background: var(--primary-gradient);
1355 | -webkit-background-clip: text;
1356 | -webkit-text-fill-color: transparent;
1357 | background-clip: text;
1358 | letter-spacing: -0.02em;
1359 | }
1360 |
1361 | .results-subtitle {
1362 | font-size: 1.1rem;
1363 | color: rgba(255, 255, 255, 0.7);
1364 | font-weight: 400;
1365 | }
1366 |
1367 | .results-gallery-hint {
1368 | font-size: 0.9rem;
1369 | color: rgba(255, 255, 255, 0.5);
1370 | font-weight: 400;
1371 | margin-top: 8px;
1372 | font-style: italic;
1373 | }
1374 |
1375 | /* Back to Gallery Button */
1376 | .back-to-gallery-btn {
1377 | display: flex;
1378 | align-items: center;
1379 | gap: 8px;
1380 | padding: 10px 20px;
1381 | font-size: 0.9rem;
1382 | font-weight: 600;
1383 | letter-spacing: 0.02em;
1384 | border-radius: 10px;
1385 | cursor: pointer;
1386 | transition: all 0.2s ease;
1387 | border: 1px solid rgba(255, 255, 255, 0.2);
1388 | background: rgba(255, 255, 255, 0.05);
1389 | color: white;
1390 | margin-bottom: 24px;
1391 | margin-left: auto;
1392 | margin-right: auto;
1393 | width: fit-content;
1394 | }
1395 |
1396 | .back-to-gallery-btn:hover {
1397 | background: rgba(255, 255, 255, 0.1);
1398 | border-color: rgba(255, 255, 255, 0.3);
1399 | transform: translateX(-4px);
1400 | }
1401 |
1402 | .back-to-gallery-btn svg {
1403 | flex-shrink: 0;
1404 | }
1405 |
1406 | /* Videos Gallery */
1407 | .videos-gallery {
1408 | display: grid;
1409 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1410 | gap: 24px;
1411 | margin-bottom: 40px;
1412 | animation: fadeInUp 0.8s ease-out;
1413 | }
1414 |
1415 | @media (max-width: 768px) {
1416 | .videos-gallery {
1417 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1418 | gap: 20px;
1419 | }
1420 | }
1421 |
1422 | /* Gallery Item */
1423 | .video-gallery-item {
1424 | background: rgba(255, 255, 255, 0.05);
1425 | border-radius: 16px;
1426 | overflow: hidden;
1427 | border: 1px solid rgba(255, 255, 255, 0.1);
1428 | backdrop-filter: blur(10px);
1429 | transition: all 0.3s ease;
1430 | cursor: pointer;
1431 | position: relative;
1432 | animation: scaleIn 0.5s ease-out;
1433 | }
1434 |
1435 | .video-gallery-item::before {
1436 | content: '';
1437 | position: absolute;
1438 | top: 0;
1439 | left: 0;
1440 | right: 0;
1441 | bottom: 0;
1442 | border-radius: 16px;
1443 | padding: 1px;
1444 | background: var(--primary-gradient);
1445 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1446 | -webkit-mask-composite: xor;
1447 | mask-composite: exclude;
1448 | pointer-events: none;
1449 | opacity: 0;
1450 | transition: opacity 0.3s ease;
1451 | z-index: 1;
1452 | }
1453 |
1454 | .video-gallery-item:hover {
1455 | transform: translateY(-6px);
1456 | box-shadow: 0 16px 48px rgba(102, 126, 234, 0.3);
1457 | border-color: rgba(255, 255, 255, 0.2);
1458 | }
1459 |
1460 | .video-gallery-item:hover::before {
1461 | opacity: 0.4;
1462 | }
1463 |
1464 | .gallery-item-thumbnail {
1465 | position: relative;
1466 | width: 100%;
1467 | aspect-ratio: 16 / 9;
1468 | overflow: hidden;
1469 | background: rgba(0, 0, 0, 0.4);
1470 | }
1471 |
1472 | .gallery-video-thumbnail {
1473 | width: 100%;
1474 | height: 100%;
1475 | object-fit: cover;
1476 | transition: transform 0.3s ease;
1477 | }
1478 |
1479 | .video-gallery-item:hover .gallery-video-thumbnail {
1480 | transform: scale(1.05);
1481 | }
1482 |
1483 | .gallery-item-overlay {
1484 | position: absolute;
1485 | top: 0;
1486 | left: 0;
1487 | right: 0;
1488 | bottom: 0;
1489 | background: rgba(0, 0, 0, 0.3);
1490 | display: flex;
1491 | align-items: center;
1492 | justify-content: center;
1493 | opacity: 0;
1494 | transition: opacity 0.3s ease;
1495 | }
1496 |
1497 | .video-gallery-item:hover .gallery-item-overlay {
1498 | opacity: 1;
1499 | }
1500 |
1501 | .gallery-play-icon {
1502 | width: 64px;
1503 | height: 64px;
1504 | background: rgba(255, 255, 255, 0.9);
1505 | border-radius: 50%;
1506 | display: flex;
1507 | align-items: center;
1508 | justify-content: center;
1509 | color: #667eea;
1510 | transform: scale(0.9);
1511 | transition: transform 0.3s ease;
1512 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
1513 | }
1514 |
1515 | .video-gallery-item:hover .gallery-play-icon {
1516 | transform: scale(1);
1517 | }
1518 |
1519 | .gallery-play-icon svg {
1520 | margin-left: 4px; /* Slight offset for play icon */
1521 | }
1522 |
1523 | .gallery-item-info {
1524 | padding: 16px;
1525 | }
1526 |
1527 | .gallery-item-title-group {
1528 | display: flex;
1529 | align-items: center;
1530 | gap: 10px;
1531 | }
1532 |
1533 | .gallery-item-flag {
1534 | width: 28px;
1535 | height: 20px;
1536 | object-fit: cover;
1537 | border-radius: 4px;
1538 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
1539 | }
1540 |
1541 | .gallery-item-title-content {
1542 | display: flex;
1543 | flex-direction: column;
1544 | gap: 4px;
1545 | flex: 1;
1546 | }
1547 |
1548 | .gallery-item-title {
1549 | font-size: 1rem;
1550 | font-weight: 600;
1551 | color: var(--text-primary);
1552 | margin: 0;
1553 | letter-spacing: -0.01em;
1554 | }
1555 |
1556 | .gallery-item-lang-code {
1557 | font-size: 0.7rem;
1558 | color: rgba(255, 255, 255, 0.5);
1559 | font-weight: 500;
1560 | text-transform: uppercase;
1561 | letter-spacing: 0.05em;
1562 | }
1563 |
1564 | /* Single Video View */
1565 | .single-video-view {
1566 | max-width: 900px;
1567 | margin: 0 auto;
1568 | animation: fadeInUp 0.8s ease-out;
1569 | }
1570 |
1571 | .single-video-card {
1572 | background: rgba(255, 255, 255, 0.05);
1573 | border-radius: 24px;
1574 | padding: 32px;
1575 | border: 1px solid rgba(255, 255, 255, 0.1);
1576 | backdrop-filter: blur(10px);
1577 | position: relative;
1578 | overflow: hidden;
1579 | }
1580 |
1581 | .single-video-card::before {
1582 | content: '';
1583 | position: absolute;
1584 | top: 0;
1585 | left: 0;
1586 | right: 0;
1587 | bottom: 0;
1588 | border-radius: 24px;
1589 | padding: 2px;
1590 | background: var(--primary-gradient);
1591 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1592 | -webkit-mask-composite: xor;
1593 | mask-composite: exclude;
1594 | pointer-events: none;
1595 | opacity: 0.3;
1596 | }
1597 |
1598 | .single-video-header {
1599 | margin-bottom: 24px;
1600 | display: flex;
1601 | align-items: center;
1602 | justify-content: space-between;
1603 | }
1604 |
1605 | .single-video-title-group {
1606 | display: flex;
1607 | align-items: center;
1608 | gap: 16px;
1609 | }
1610 |
1611 | .single-video-flag {
1612 | width: 40px;
1613 | height: 30px;
1614 | object-fit: cover;
1615 | border-radius: 6px;
1616 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1617 | }
1618 |
1619 | .single-video-title-content {
1620 | display: flex;
1621 | flex-direction: column;
1622 | gap: 6px;
1623 | }
1624 |
1625 | .single-video-title {
1626 | font-size: 1.5rem;
1627 | font-weight: 700;
1628 | color: var(--text-primary);
1629 | margin: 0;
1630 | letter-spacing: -0.02em;
1631 | }
1632 |
1633 | .single-video-lang-code {
1634 | font-size: 0.85rem;
1635 | color: rgba(255, 255, 255, 0.6);
1636 | font-weight: 600;
1637 | text-transform: uppercase;
1638 | letter-spacing: 0.1em;
1639 | }
1640 |
1641 | .single-video-player {
1642 | width: 100%;
1643 | margin-bottom: 24px;
1644 | border-radius: 20px;
1645 | overflow: hidden;
1646 | background: rgba(0, 0, 0, 0.5);
1647 | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
1648 | position: relative;
1649 | }
1650 |
1651 | .single-video-element {
1652 | width: 100%;
1653 | display: block;
1654 | max-height: 70vh;
1655 | border-radius: 20px;
1656 | }
1657 |
1658 | .single-video-actions {
1659 | display: flex;
1660 | justify-content: center;
1661 | gap: 16px;
1662 | }
1663 |
1664 | .single-video-download-btn {
1665 | display: flex;
1666 | align-items: center;
1667 | gap: 10px;
1668 | padding: 16px 32px;
1669 | font-size: 1rem;
1670 | font-weight: 600;
1671 | letter-spacing: 0.02em;
1672 | border-radius: 12px;
1673 | cursor: pointer;
1674 | transition: all 0.2s ease;
1675 | border: none;
1676 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1677 | color: white;
1678 | box-shadow: 0 6px 24px rgba(102, 126, 234, 0.4);
1679 | min-width: 200px;
1680 | justify-content: center;
1681 | }
1682 |
1683 | .single-video-download-btn:hover {
1684 | transform: translateY(-3px);
1685 | box-shadow: 0 10px 32px rgba(102, 126, 234, 0.5);
1686 | }
1687 |
1688 | .single-video-download-btn svg {
1689 | flex-shrink: 0;
1690 | }
1691 |
1692 | /* Videos Grid */
1693 | .videos-grid {
1694 | display: grid;
1695 | grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
1696 | gap: 30px;
1697 | margin-bottom: 40px;
1698 | animation: fadeInUp 0.8s ease-out;
1699 | }
1700 |
1701 | @media (max-width: 768px) {
1702 | .videos-grid {
1703 | grid-template-columns: 1fr;
1704 | gap: 24px;
1705 | }
1706 | }
1707 |
1708 | /* Video Result Card */
1709 | .video-result-card {
1710 | background: rgba(255, 255, 255, 0.05);
1711 | border-radius: 20px;
1712 | padding: 24px;
1713 | border: 1px solid rgba(255, 255, 255, 0.1);
1714 | backdrop-filter: blur(10px);
1715 | transition: all 0.3s ease;
1716 | position: relative;
1717 | overflow: hidden;
1718 | animation: scaleIn 0.5s ease-out;
1719 | }
1720 |
1721 | .video-result-card::before {
1722 | content: '';
1723 | position: absolute;
1724 | top: 0;
1725 | left: 0;
1726 | right: 0;
1727 | bottom: 0;
1728 | border-radius: 20px;
1729 | padding: 1px;
1730 | background: var(--primary-gradient);
1731 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1732 | -webkit-mask-composite: xor;
1733 | mask-composite: exclude;
1734 | pointer-events: none;
1735 | opacity: 0;
1736 | transition: opacity 0.3s ease;
1737 | }
1738 |
1739 | .video-result-card:hover {
1740 | transform: translateY(-4px);
1741 | box-shadow: 0 12px 40px rgba(102, 126, 234, 0.2);
1742 | border-color: rgba(255, 255, 255, 0.2);
1743 | }
1744 |
1745 | .video-result-card:hover::before {
1746 | opacity: 0.3;
1747 | }
1748 |
1749 | .video-card-header {
1750 | margin-bottom: 20px;
1751 | display: flex;
1752 | align-items: center;
1753 | justify-content: space-between;
1754 | }
1755 |
1756 | .video-card-title-group {
1757 | display: flex;
1758 | align-items: center;
1759 | gap: 12px;
1760 | }
1761 |
1762 | .video-card-flag {
1763 | width: 32px;
1764 | height: 24px;
1765 | object-fit: cover;
1766 | border-radius: 4px;
1767 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1768 | }
1769 |
1770 | .video-card-title-content {
1771 | display: flex;
1772 | flex-direction: column;
1773 | gap: 4px;
1774 | }
1775 |
1776 | .video-card-title {
1777 | font-size: 1.25rem;
1778 | font-weight: 600;
1779 | color: var(--text-primary);
1780 | margin: 0;
1781 | letter-spacing: -0.01em;
1782 | }
1783 |
1784 | .video-card-lang-code {
1785 | font-size: 0.75rem;
1786 | color: rgba(255, 255, 255, 0.5);
1787 | font-weight: 500;
1788 | text-transform: uppercase;
1789 | letter-spacing: 0.05em;
1790 | }
1791 |
1792 | .video-card-player {
1793 | width: 100%;
1794 | margin-bottom: 20px;
1795 | border-radius: 16px;
1796 | overflow: hidden;
1797 | background: rgba(0, 0, 0, 0.4);
1798 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
1799 | position: relative;
1800 | }
1801 |
1802 | .result-video-card {
1803 | width: 100%;
1804 | display: block;
1805 | max-height: 400px;
1806 | border-radius: 16px;
1807 | }
1808 |
1809 | .video-card-actions {
1810 | display: flex;
1811 | justify-content: center;
1812 | gap: 12px;
1813 | }
1814 |
1815 | .video-card-download-btn {
1816 | display: flex;
1817 | align-items: center;
1818 | gap: 8px;
1819 | padding: 12px 24px;
1820 | font-size: 0.9375rem;
1821 | font-weight: 600;
1822 | letter-spacing: 0.02em;
1823 | border-radius: 10px;
1824 | cursor: pointer;
1825 | transition: all 0.2s ease;
1826 | border: none;
1827 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1828 | color: white;
1829 | box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
1830 | width: 100%;
1831 | justify-content: center;
1832 | }
1833 |
1834 | .video-card-download-btn:hover {
1835 | transform: translateY(-2px);
1836 | box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
1837 | }
1838 |
1839 | .video-card-download-btn svg {
1840 | flex-shrink: 0;
1841 | }
1842 |
1843 | .results-footer-actions {
1844 | display: flex;
1845 | justify-content: center;
1846 | margin-top: 40px;
1847 | padding-top: 30px;
1848 | border-top: 1px solid rgba(255, 255, 255, 0.1);
1849 | animation: fadeIn 0.8s ease-out 0.3s both;
1850 | }
1851 |
1852 | .action-buttons {
1853 | display: flex;
1854 | gap: 20px;
1855 | justify-content: center;
1856 | margin-top: 30px;
1857 | flex-wrap: wrap;
1858 | }
1859 |
1860 | .action-btn {
1861 | display: flex;
1862 | align-items: center;
1863 | gap: 10px;
1864 | padding: 14px 28px;
1865 | font-size: 0.9375rem;
1866 | font-weight: 600;
1867 | letter-spacing: 0.02em;
1868 | border-radius: 10px;
1869 | cursor: pointer;
1870 | transition: all 0.2s ease;
1871 | border: none;
1872 | }
1873 |
1874 | .download-btn {
1875 | background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
1876 | color: white;
1877 | box-shadow: 0 6px 24px rgba(37, 99, 235, 0.4);
1878 | }
1879 |
1880 | .download-btn:hover {
1881 | transform: translateY(-3px);
1882 | box-shadow: 0 10px 32px rgba(37, 99, 235, 0.5);
1883 | }
1884 |
1885 | .translate-another-btn {
1886 | background: rgba(255, 255, 255, 0.1);
1887 | color: white;
1888 | border: 2px solid rgba(255, 255, 255, 0.3);
1889 | }
1890 |
1891 | .translate-another-btn:hover {
1892 | background: rgba(255, 255, 255, 0.2);
1893 | border-color: rgba(255, 255, 255, 0.5);
1894 | transform: translateY(-3px);
1895 | }
1896 |
1897 | .action-btn svg {
1898 | flex-shrink: 0;
1899 | }
1900 |
1901 | /* Processing Popup */
1902 | .processing-popup-overlay {
1903 | position: fixed;
1904 | top: 0;
1905 | left: 0;
1906 | width: 100%;
1907 | height: 100%;
1908 | background: rgba(0, 0, 0, 0.85);
1909 | backdrop-filter: blur(12px);
1910 | display: flex;
1911 | justify-content: center;
1912 | align-items: center;
1913 | z-index: 2000;
1914 | animation: fadeIn 0.3s ease-out;
1915 | }
1916 |
1917 | .processing-popup {
1918 | background: rgba(21, 21, 26, 0.98);
1919 | padding: 48px 40px;
1920 | border-radius: 24px;
1921 | box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6),
1922 | 0 0 0 1px rgba(255, 255, 255, 0.08),
1923 | 0 0 40px rgba(102, 126, 234, 0.15);
1924 | text-align: center;
1925 | backdrop-filter: blur(20px);
1926 | max-width: 480px;
1927 | width: 90%;
1928 | position: relative;
1929 | animation: scaleIn 0.3s ease-out;
1930 | overflow: hidden;
1931 | }
1932 |
1933 | .processing-popup::before {
1934 | content: '';
1935 | position: absolute;
1936 | top: 0;
1937 | left: 0;
1938 | right: 0;
1939 | bottom: 0;
1940 | border-radius: 24px;
1941 | padding: 2px;
1942 | background: var(--primary-gradient);
1943 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1944 | -webkit-mask-composite: xor;
1945 | mask-composite: exclude;
1946 | pointer-events: none;
1947 | opacity: 0.6;
1948 | }
1949 |
1950 | @keyframes scaleIn {
1951 | from {
1952 | transform: scale(0.9);
1953 | opacity: 0;
1954 | }
1955 | to {
1956 | transform: scale(1);
1957 | opacity: 1;
1958 | }
1959 | }
1960 |
1961 | .processing-popup h3 {
1962 | color: white;
1963 | font-size: 1.75rem;
1964 | margin-bottom: 16px;
1965 | font-weight: 700;
1966 | background: var(--primary-gradient);
1967 | -webkit-background-clip: text;
1968 | -webkit-text-fill-color: transparent;
1969 | background-clip: text;
1970 | letter-spacing: -0.02em;
1971 | position: relative;
1972 | z-index: 1;
1973 | }
1974 |
1975 | .processing-popup p {
1976 | color: rgba(255, 255, 255, 0.85);
1977 | font-size: 1rem;
1978 | margin-bottom: 32px;
1979 | line-height: 1.6;
1980 | letter-spacing: 0.01em;
1981 | position: relative;
1982 | z-index: 1;
1983 | }
1984 |
1985 | .progress-container {
1986 | margin-top: 28px;
1987 | width: 100%;
1988 | position: relative;
1989 | z-index: 1;
1990 | }
1991 |
1992 | .progress-bar {
1993 | width: 100%;
1994 | height: 10px;
1995 | background: rgba(255, 255, 255, 0.08);
1996 | border-radius: 12px;
1997 | overflow: hidden;
1998 | margin-bottom: 12px;
1999 | box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3);
2000 | border: 1px solid rgba(255, 255, 255, 0.05);
2001 | }
2002 |
2003 | .progress-fill {
2004 | height: 100%;
2005 | background: linear-gradient(90deg, #667eea, #7a8ef0);
2006 | border-radius: 12px;
2007 | transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
2008 | position: relative;
2009 | overflow: hidden;
2010 | box-shadow: 0 0 16px rgba(102, 126, 234, 0.6), 0 2px 8px rgba(102, 126, 234, 0.4);
2011 | }
2012 |
2013 | .progress-text {
2014 | color: rgba(255, 255, 255, 0.9);
2015 | font-size: 0.95rem;
2016 | font-weight: 600;
2017 | display: block;
2018 | letter-spacing: 0.02em;
2019 | background: var(--primary-gradient);
2020 | -webkit-background-clip: text;
2021 | -webkit-text-fill-color: transparent;
2022 | background-clip: text;
2023 | }
2024 |
2025 | .processing-popup.error {
2026 | background: rgba(21, 21, 26, 0.98);
2027 | }
2028 |
2029 | .processing-popup.error::before {
2030 | background: linear-gradient(135deg, #ef4444 0%, #dc2626 50%, #b91c1c 100%);
2031 | opacity: 0.5;
2032 | }
2033 |
2034 | .processing-popup.error p {
2035 | color: rgba(255, 255, 255, 0.9);
2036 | margin-bottom: 24px;
2037 | }
2038 |
2039 | .language-progress-list {
2040 | margin-top: 24px;
2041 | width: 100%;
2042 | max-height: 200px;
2043 | overflow-y: auto;
2044 | display: flex;
2045 | flex-direction: column;
2046 | gap: 12px;
2047 | }
2048 |
2049 | .language-progress-item {
2050 | background: rgba(255, 255, 255, 0.05);
2051 | border-radius: 8px;
2052 | padding: 12px;
2053 | border: 1px solid rgba(255, 255, 255, 0.1);
2054 | }
2055 |
2056 | .language-progress-header {
2057 | display: flex;
2058 | justify-content: space-between;
2059 | align-items: center;
2060 | margin-bottom: 8px;
2061 | }
2062 |
2063 | .language-progress-name {
2064 | font-size: 0.9rem;
2065 | font-weight: 600;
2066 | color: var(--text-primary);
2067 | }
2068 |
2069 | .language-progress-status {
2070 | font-size: 0.75rem;
2071 | font-weight: 600;
2072 | padding: 4px 10px;
2073 | border-radius: 12px;
2074 | text-transform: uppercase;
2075 | letter-spacing: 0.05em;
2076 | }
2077 |
2078 | .language-progress-status.status-1 {
2079 | background: rgba(59, 130, 246, 0.2);
2080 | color: #60a5fa;
2081 | }
2082 |
2083 | .language-progress-status.status-2 {
2084 | background: rgba(251, 191, 36, 0.2);
2085 | color: #fbbf24;
2086 | }
2087 |
2088 | .language-progress-status.status-3 {
2089 | background: rgba(16, 185, 129, 0.2);
2090 | color: #10b981;
2091 | }
2092 |
2093 | .language-progress-status.status-4 {
2094 | background: rgba(239, 68, 68, 0.2);
2095 | color: #ef4444;
2096 | }
2097 |
2098 | .language-progress-bar {
2099 | width: 100%;
2100 | height: 6px;
2101 | background: rgba(255, 255, 255, 0.08);
2102 | border-radius: 6px;
2103 | overflow: hidden;
2104 | }
2105 |
2106 | .language-progress-fill {
2107 | height: 100%;
2108 | background: linear-gradient(90deg, #667eea, #7a8ef0);
2109 | border-radius: 6px;
2110 | transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
2111 | }
2112 |
2113 | .processing-popup.error button {
2114 | background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
2115 | margin-top: 24px;
2116 | width: auto;
2117 | padding: 14px 36px;
2118 | border-radius: 12px;
2119 | color: white;
2120 | font-weight: 600;
2121 | font-size: 0.9375rem;
2122 | letter-spacing: 0.02em;
2123 | border: none;
2124 | cursor: pointer;
2125 | transition: all 0.3s ease;
2126 | box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
2127 | position: relative;
2128 | z-index: 1;
2129 | overflow: hidden;
2130 | }
2131 |
2132 | .processing-popup.error button::before {
2133 | content: '';
2134 | position: absolute;
2135 | top: 50%;
2136 | left: 50%;
2137 | width: 0;
2138 | height: 0;
2139 | border-radius: 50%;
2140 | background: rgba(255, 255, 255, 0.2);
2141 | transform: translate(-50%, -50%);
2142 | transition: width 0.6s, height 0.6s;
2143 | }
2144 |
2145 | .processing-popup.error button:hover::before {
2146 | width: 300px;
2147 | height: 300px;
2148 | }
2149 |
2150 | .processing-popup.error button:hover {
2151 | transform: translateY(-2px);
2152 | box-shadow: 0 6px 24px rgba(239, 68, 68, 0.5);
2153 | }
2154 |
2155 | /* Animations */
2156 | @keyframes fadeIn {
2157 | from {
2158 | opacity: 0;
2159 | }
2160 | to {
2161 | opacity: 1;
2162 | }
2163 | }
2164 |
2165 | @keyframes fadeInUp {
2166 | from {
2167 | opacity: 0;
2168 | transform: translateY(20px);
2169 | }
2170 | to {
2171 | opacity: 1;
2172 | transform: translateY(0);
2173 | }
2174 | }
2175 |
2176 | @keyframes shimmer {
2177 | 0% {
2178 | background-position: -1000px 0;
2179 | }
2180 | 100% {
2181 | background-position: 1000px 0;
2182 | }
2183 | }
2184 |
2185 | .animate-fade-in {
2186 | animation: fadeIn 0.6s ease-out;
2187 | }
2188 |
2189 | /* Responsive Design */
2190 | @media (max-width: 1024px) {
2191 | .app {
2192 | max-width: 95%;
2193 | padding: 25px 20px;
2194 | }
2195 |
2196 | .translation-workspace-horizontal {
2197 | grid-template-columns: 1fr;
2198 | gap: 24px;
2199 | max-height: none;
2200 | }
2201 |
2202 | .inputs-section {
2203 | max-height: none;
2204 | overflow-y: visible;
2205 | }
2206 |
2207 | .video-section {
2208 | position: static;
2209 | max-height: none;
2210 | overflow-y: visible;
2211 | }
2212 |
2213 | .language-selector-grid {
2214 | grid-template-columns: 1fr;
2215 | gap: 15px;
2216 | }
2217 |
2218 | .arrow-icon {
2219 | transform: rotate(90deg);
2220 | margin: 0 auto;
2221 | }
2222 | }
2223 |
2224 | @media (max-width: 768px) {
2225 | body {
2226 | padding: 10px;
2227 | }
2228 |
2229 | .app {
2230 | padding: 20px 15px;
2231 | border-radius: 20px;
2232 | }
2233 |
2234 | .translation-workspace-horizontal {
2235 | max-height: none;
2236 | }
2237 |
2238 | .inputs-section {
2239 | max-height: none;
2240 | overflow-y: visible;
2241 | }
2242 |
2243 | .video-section {
2244 | max-height: none;
2245 | overflow-y: visible;
2246 | }
2247 |
2248 | .app-header h1 {
2249 | font-size: 2rem;
2250 | }
2251 |
2252 | .app-header .logo img {
2253 | width: 140px;
2254 | }
2255 |
2256 | .auth-container {
2257 | padding: 25px 15px;
2258 | }
2259 |
2260 | .auth-tabs {
2261 | padding: 6px;
2262 | margin-bottom: 25px;
2263 | }
2264 |
2265 | .auth-tab {
2266 | padding: 12px 20px;
2267 | font-size: 0.9rem;
2268 | }
2269 |
2270 | .auth-content {
2271 | padding: 30px 20px;
2272 | }
2273 |
2274 | .translation-workspace {
2275 | gap: 20px;
2276 | }
2277 |
2278 | .video-preview-section,
2279 | .input-card,
2280 | .language-selection-card,
2281 | .voice-selection-card,
2282 | .options-card,
2283 | .advanced-settings-card {
2284 | padding: 20px;
2285 | border-radius: 16px;
2286 | }
2287 |
2288 | .section-header h2,
2289 | .card-header h3 {
2290 | font-size: 1.2rem;
2291 | }
2292 |
2293 | .options-grid {
2294 | grid-template-columns: 1fr;
2295 | }
2296 |
2297 | .voice-grid {
2298 | grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
2299 | gap: 15px;
2300 | }
2301 |
2302 | .translate-btn-primary {
2303 | padding: 18px 40px;
2304 | font-size: 1.1rem;
2305 | }
2306 |
2307 | .action-buttons {
2308 | flex-direction: column;
2309 | }
2310 |
2311 | .action-btn {
2312 | width: 100%;
2313 | justify-content: center;
2314 | }
2315 | }
2316 |
2317 | @media (max-width: 480px) {
2318 | body {
2319 | padding: 5px;
2320 | }
2321 |
2322 | .app {
2323 | padding: 15px 10px;
2324 | border-radius: 16px;
2325 | }
2326 |
2327 | .app-header .logo {
2328 | flex-direction: column;
2329 | gap: 10px;
2330 | }
2331 |
2332 | .app-header .logo img {
2333 | width: 120px;
2334 | }
2335 |
2336 | .app-header h1 {
2337 | font-size: 1.6rem;
2338 | }
2339 |
2340 | .language-selector-container {
2341 | top: 0.5rem;
2342 | right: 0.5rem;
2343 | }
2344 |
2345 | .language-selector-container select {
2346 | padding: 8px 35px 8px 12px;
2347 | font-size: 0.85rem;
2348 | min-width: 110px;
2349 | }
2350 |
2351 | .auth-tab {
2352 | padding: 10px 16px;
2353 | font-size: 0.85rem;
2354 | }
2355 |
2356 | .video-preview-container {
2357 | min-height: 250px;
2358 | }
2359 |
2360 | .source-video-preview {
2361 | max-height: 300px;
2362 | }
2363 |
2364 | .video-placeholder {
2365 | padding: 40px 15px;
2366 | min-height: 250px;
2367 | }
2368 |
2369 | .placeholder-icon {
2370 | font-size: 3rem;
2371 | }
2372 |
2373 | .modern-input,
2374 | .modern-select {
2375 | padding: 14px 16px;
2376 | font-size: 0.95rem;
2377 | }
2378 |
2379 | .translate-btn-primary {
2380 | padding: 16px 30px;
2381 | font-size: 1rem;
2382 | width: 100%;
2383 | }
2384 |
2385 | .processing-popup {
2386 | padding: 30px 20px;
2387 | }
2388 |
2389 | .processing-popup h3 {
2390 | font-size: 1.4rem;
2391 | }
2392 | }
2393 |
2394 | /* Prevent horizontal scroll */
2395 | html, body {
2396 | overflow-x: hidden;
2397 | max-width: 100vw;
2398 | }
2399 |
2400 | .app,
2401 | .auth-container,
2402 | .main-content,
2403 | .result-container {
2404 | max-width: 100vw;
2405 | overflow-x: hidden;
2406 | }
2407 |
--------------------------------------------------------------------------------
/video-translation-frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react';
2 | import axios from 'axios';
3 | import './App.css';
4 | import {io, Socket} from 'socket.io-client'
5 | import { useTranslation } from 'react-i18next';
6 | import LanguageSelector from './components/LanguageSelector';
7 | import './i18n';
8 |
9 | interface Language {
10 | lang_code: string;
11 | lang_name: string;
12 | url: string;
13 | need_voice_id?: boolean;
14 | flag_url?: string;
15 | }
16 |
17 | interface Voice {
18 | _id: string;
19 | voice_id: string;
20 | gender: string;
21 | language: string;
22 | name: string;
23 | preview: string;
24 | thumbnailUrl: string;
25 | flag_url: string;
26 | language_code: string;
27 | age?: string[];
28 | style?: string[];
29 | scenario?: string[];
30 | }
31 |
32 | const App: React.FC = () => {
33 | const [authMethod, setAuthMethod] = useState<'apiKey' | 'credentials'>('apiKey'); // Auth method selection
34 | const [apiKey, setApiKey] = useState(''); // API Key for direct API calls
35 | const [apiKeyInput, setApiKeyInput] = useState(''); // API Key input field
36 | const [bearerToken, setBearerToken] = useState(''); // Bearer token from credentials
37 | const [clientId, setClientId] = useState(''); // Client ID for credentials
38 | const [clientSecret, setClientSecret] = useState(''); // Client Secret for credentials
39 | const [isTokenLoading, setIsTokenLoading] = useState(false); // Loading state for token generation
40 | const [languages, setLanguages] = useState([]); // Fetched languages
41 | const [sourceLanguage, setSourceLanguage] = useState(null); // Source Language selection
42 | const [targetLanguages, setTargetLanguages] = useState([]); // Target Languages selection (multiple)
43 | const [selectedVoices, setSelectedVoices] = useState<{ [langCode: string]: string }>({}); // Selected voice IDs mapped by language code
44 | const [availableVoices, setAvailableVoices] = useState<{ [langCode: string]: Voice[] }>({}); // Voices available for each language
45 | const [videoUrl, setVideoUrl] = useState(''); // URL for translation
46 | const [lipSync, setLipSync] = useState(true); // Lip sync checkbox (enabled by default)
47 | const [speakerNum, setSpeakerNum] = useState(0); // Number of speakers (0 = auto-detect, 1-10)
48 | const [removeBgm, setRemoveBgm] = useState(false); // Remove background music
49 | const [captionType, setCaptionType] = useState(0); // Caption type (0-4)
50 | const [dynamicDuration, setDynamicDuration] = useState(false); // Dynamic video length
51 | const [captionUrl, setCaptionUrl] = useState(''); // Caption file URL (SRT/ASS)
52 | const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); // Advanced settings visibility
53 | const [videoPreviewError, setVideoPreviewError] = useState(null); // Video preview error
54 | const [error, setError] = useState(null); // Error state
55 | const [fetchingLanguages, setFetchingLanguages] = useState(false); // Loader for language fetch
56 | const [isTranslating, setIsTranslating] = useState(false); // Loader for translation process
57 | const [translationResult, setTranslationResult] = useState(null);
58 | const languageListingUrl = '/api/open/v3/language/list'; // Language API URL (proxied through Vite)
59 | const voicesUrl = '/api/open/v4/voice/videoTranslation'; // Voices API URL (proxied through Vite)
60 | const socket = useRef();
61 | const [processingVideos, setProcessingVideos] = useState<{ [langCode: string]: string }>({}); // Map of language code to video URL
62 | const [videoModelIds, setVideoModelIds] = useState<{ [langCode: string]: string }>({}); // Map of language code to model ID
63 | const [videoStatuses, setVideoStatuses] = useState<{ [langCode: string]: number }>({}); // Map of language code to status
64 | const [videoProgress, setVideoProgress] = useState<{ [langCode: string]: number }>({}); // Map of language code to progress
65 | const [overallProgress, setOverallProgress] = useState(0);
66 | const [selectedVideoLangCode, setSelectedVideoLangCode] = useState(null); // Selected video for full preview
67 | const [showProcessingPopup, setShowProcessingPopup] = useState(false);
68 | const [showErrorPopup, setShowErrorPopup] = useState(false);
69 | const [errorMessage, setErrorMessage] = useState('');
70 | const [pollingInterval, setPollingInterval] = useState | null>(null);
71 | const { t } = useTranslation();
72 |
73 | // Helper function to get authentication headers
74 | const getAuthHeaders = () => {
75 | if (authMethod === 'apiKey' && apiKey) {
76 | // Use x-api-key header for API key authentication
77 | return { 'x-api-key': apiKey };
78 | } else if (authMethod === 'credentials' && bearerToken) {
79 | return { 'Authorization': `Bearer ${bearerToken}` };
80 | }
81 | return {};
82 | };
83 |
84 | // Check if user is authenticated
85 | const isAuthenticated = () => {
86 | return (authMethod === 'apiKey' && apiKey) || (authMethod === 'credentials' && bearerToken);
87 | };
88 |
89 |
90 |
91 | useEffect(() => {
92 | socket.current = io('http://localhost:3007');
93 | socket.current.on("connect", () => {
94 | console.log("Connected to WebSocket server");
95 | });
96 |
97 | socket.current.on("message", async (msg: any) => {
98 | if (msg.type === 'event') {
99 | console.log("GETTING DATA from websocket:::", msg.data);
100 |
101 | // Handle status updates with model ID
102 | // Only process if we have active model IDs (not in reset state)
103 | if (msg.data._id && msg.data.video_status) {
104 | // Find which language this model ID belongs to using functional update
105 | setVideoModelIds(currentModelIds => {
106 | // Ignore messages if we've reset (no active model IDs)
107 | if (Object.keys(currentModelIds).length === 0) {
108 | return currentModelIds;
109 | }
110 |
111 | const langCode = Object.keys(currentModelIds).find(
112 | key => currentModelIds[key] === msg.data._id
113 | );
114 |
115 | if (langCode) {
116 | // Update status for this specific language
117 | setVideoStatuses(prev => {
118 | const updated = { ...prev, [langCode]: msg.data.video_status };
119 |
120 | // Check if all videos are done (completed OR failed)
121 | const allDone = Object.values(updated).every(status => status === 3 || status === 4);
122 |
123 | if (allDone) {
124 | setShowProcessingPopup(false);
125 | if (pollingInterval) {
126 | clearInterval(pollingInterval);
127 | setPollingInterval(null);
128 | }
129 | }
130 |
131 | return updated;
132 | });
133 |
134 | // Only update progress if not failed (failed videos don't count towards progress)
135 | if (msg.data.video_status !== 4) {
136 | setVideoProgress(prev => ({ ...prev, [langCode]: msg.data.progress || 0 }));
137 | } else {
138 | // Set failed videos to 0 progress so they don't affect average
139 | setVideoProgress(prev => ({ ...prev, [langCode]: 0 }));
140 | }
141 |
142 | // If video is completed, update the video URL
143 | if (msg.data.video_status === 3 && msg.data.url) {
144 | setProcessingVideos(prev => ({ ...prev, [langCode]: msg.data.url }));
145 | }
146 | } else {
147 | // Fallback: check status for this model ID
148 | checkVideoStatusForModelId(msg.data._id);
149 | }
150 |
151 | return currentModelIds;
152 | });
153 | }
154 |
155 | // Handle completed video URL (legacy support)
156 | // Only process if we have active model IDs (not in reset state)
157 | if (msg.data.url && !msg.data._id) {
158 | setVideoModelIds(currentModelIds => {
159 | // Only process if we have active translations
160 | if (Object.keys(currentModelIds).length === 0) {
161 | return currentModelIds; // Ignore if we've reset
162 | }
163 |
164 | const langCode = msg.data.language_code || msg.data.language;
165 | if (langCode) {
166 | setProcessingVideos(prev => ({ ...prev, [langCode]: msg.data.url }));
167 | setVideoStatuses(prev => ({ ...prev, [langCode]: 3 }));
168 | setVideoProgress(prev => ({ ...prev, [langCode]: 100 }));
169 | }
170 |
171 | return currentModelIds;
172 | });
173 | }
174 | } else if (msg.type === 'error') {
175 | setShowProcessingPopup(false);
176 | setErrorMessage(msg.message);
177 | setShowErrorPopup(true);
178 | // Mark all as failed if it's a general error
179 | setVideoModelIds(currentModelIds => {
180 | const failedStatuses: { [key: string]: number } = {};
181 | Object.keys(currentModelIds).forEach(langCode => {
182 | failedStatuses[langCode] = 4;
183 | });
184 | setVideoStatuses(failedStatuses);
185 | return currentModelIds;
186 | });
187 | if (pollingInterval) {
188 | clearInterval(pollingInterval);
189 | setPollingInterval(null);
190 | }
191 | }
192 | });
193 |
194 | return () => {
195 | socket.current?.close();
196 | if (pollingInterval) {
197 | clearInterval(pollingInterval);
198 | }
199 | }
200 | }, [apiKey]);
201 |
202 | useEffect(() => {
203 | if (isAuthenticated()) {
204 | setFetchingLanguages(true);
205 | fetchLanguages();
206 | }
207 | }, [apiKey, bearerToken, authMethod]);
208 |
209 | useEffect(() => {
210 | // Fetch voices for all selected target languages
211 | const fetchVoicesForLanguages = async () => {
212 | const voicesMap: { [langCode: string]: Voice[] } = {};
213 |
214 | for (const langCode of targetLanguages) {
215 | const lang = languages.find(l => l.lang_code === langCode);
216 | if (lang?.need_voice_id) {
217 | try {
218 | const response = await axios.get(voicesUrl, {
219 | headers: getAuthHeaders(),
220 | params: {
221 | language_code: langCode,
222 | page: 1,
223 | size: 100
224 | }
225 | });
226 | const data = response.data;
227 | if (data.code === 1000) {
228 | voicesMap[langCode] = data.data.result || [];
229 | }
230 | } catch (err) {
231 | console.error(`Error fetching voices for ${langCode}:`, err);
232 | voicesMap[langCode] = [];
233 | }
234 | }
235 | }
236 |
237 | setAvailableVoices(voicesMap);
238 | };
239 |
240 | if (targetLanguages.length > 0 && languages.length > 0) {
241 | fetchVoicesForLanguages();
242 | } else {
243 | setAvailableVoices({});
244 | setSelectedVoices({});
245 | }
246 | }, [targetLanguages, languages, apiKey, bearerToken, authMethod]);
247 |
248 | const fetchLanguages = async () => {
249 | if (!isAuthenticated()) return;
250 |
251 | setError(null);
252 | setLanguages([]);
253 | setSourceLanguage(null);
254 | setTargetLanguages([]);
255 | setSelectedVoices({});
256 | setAvailableVoices({});
257 | setLipSync(true);
258 | try {
259 | const response = await axios.get(languageListingUrl, {
260 | headers: getAuthHeaders(),
261 | });
262 | const data = response.data;
263 | if (data.code === 1000) {
264 | setLanguages(data.data.lang_list || []);
265 | } else {
266 | setError(data.msg || t('errors.fetchError'));
267 | }
268 | } catch (err) {
269 | setError(t('errors.fetchError'));
270 | console.error(err);
271 | } finally {
272 | setFetchingLanguages(false);
273 | }
274 | };
275 |
276 | // Helper to add target language
277 | const addTargetLanguage = (langCode: string) => {
278 | if (!targetLanguages.includes(langCode)) {
279 | setTargetLanguages([...targetLanguages, langCode]);
280 | }
281 | };
282 |
283 | // Helper to remove target language
284 | const removeTargetLanguage = (langCode: string) => {
285 | setTargetLanguages(targetLanguages.filter(lang => lang !== langCode));
286 | // Remove voice selection for this language
287 | const newSelectedVoices = { ...selectedVoices };
288 | delete newSelectedVoices[langCode];
289 | setSelectedVoices(newSelectedVoices);
290 | };
291 |
292 | // Helper to set voice for a language
293 | const setVoiceForLanguage = (langCode: string, voiceId: string) => {
294 | setSelectedVoices({ ...selectedVoices, [langCode]: voiceId });
295 | };
296 |
297 | // Check video status for a specific model ID and language
298 | const checkVideoStatusForModelId = async (modelId: string, langCode?: string) => {
299 | if (!isAuthenticated() || !modelId) return;
300 |
301 | try {
302 | const response = await axios.get(
303 | `/api/open/v3/content/video/infobymodelid`,
304 | {
305 | headers: getAuthHeaders(),
306 | params: {
307 | video_model_id: modelId
308 | }
309 | }
310 | );
311 |
312 | if (response.data.code === 1000) {
313 | // Check if we still have active model IDs (not reset) before processing
314 | setVideoModelIds(currentModelIds => {
315 | if (Object.keys(currentModelIds).length === 0) {
316 | return currentModelIds; // Ignore if reset
317 | }
318 |
319 | // Only process if this model ID is still tracked
320 | if (!Object.values(currentModelIds).includes(modelId)) {
321 | return currentModelIds; // Ignore if model ID not in current session
322 | }
323 |
324 | const videoData = response.data.data;
325 | const detectedLangCode = langCode || videoData.language || 'default';
326 |
327 | // Update status and progress for this language
328 | setVideoStatuses(prev => {
329 | const updated = { ...prev, [detectedLangCode]: videoData.video_status };
330 |
331 | // Check if all videos are done (completed OR failed)
332 | const allDone = Object.values(updated).every(status => status === 3 || status === 4);
333 |
334 | if (allDone) {
335 | setShowProcessingPopup(false);
336 | if (pollingInterval) {
337 | clearInterval(pollingInterval);
338 | setPollingInterval(null);
339 | }
340 | }
341 |
342 | return updated;
343 | });
344 |
345 | // Only update progress if not failed (failed videos don't count towards progress)
346 | if (videoData.video_status !== 4) {
347 | setVideoProgress(prev => ({ ...prev, [detectedLangCode]: videoData.progress || 0 }));
348 | } else {
349 | // Set failed videos to 0 progress so they don't affect average
350 | setVideoProgress(prev => ({ ...prev, [detectedLangCode]: 0 }));
351 | }
352 |
353 | if (videoData.video_status === 3 && videoData.video) {
354 | // Completed - update video URL
355 | setProcessingVideos(prev => ({ ...prev, [detectedLangCode]: videoData.video }));
356 | } else if (videoData.video_status === 4) {
357 | // Failed for this specific language
358 | setErrorMessage(videoData.error_reason || t('errors.translationError'));
359 | // Don't close popup if other languages are still processing
360 | }
361 |
362 | return currentModelIds;
363 | });
364 | }
365 | } catch (err) {
366 | console.error('Error checking video status:', err);
367 | }
368 | };
369 |
370 | // Check status for all video model IDs
371 | const checkAllVideoStatuses = async () => {
372 | // Use functional update to get current state
373 | setVideoModelIds(currentModelIds => {
374 | const modelIdEntries = Object.entries(currentModelIds);
375 | if (modelIdEntries.length === 0) return currentModelIds;
376 |
377 | // Check all model IDs in parallel
378 | Promise.all(
379 | modelIdEntries.map(([langCode, modelId]) =>
380 | checkVideoStatusForModelId(modelId, langCode)
381 | )
382 | ).then(() => {
383 | // Calculate overall progress after updates (excluding failed videos)
384 | setVideoProgress(currentProgress => {
385 | setVideoStatuses(currentStatuses => {
386 | // Only count progress for videos that are not failed
387 | const activeProgressEntries = Object.entries(currentProgress).filter(([langCode]) => {
388 | return currentStatuses[langCode] !== 4; // Exclude failed videos
389 | });
390 |
391 | if (activeProgressEntries.length > 0) {
392 | const progressValues = activeProgressEntries.map(([, progress]) => progress);
393 | const avgProgress = progressValues.reduce((sum, p) => sum + p, 0) / progressValues.length;
394 | setOverallProgress(Math.round(avgProgress));
395 | } else {
396 | // If all failed, set progress to 0
397 | setOverallProgress(0);
398 | }
399 |
400 | return currentStatuses;
401 | });
402 | return currentProgress;
403 | });
404 | });
405 |
406 | return currentModelIds;
407 | });
408 | };
409 |
410 | const handleTranslate = async () => {
411 | if (!videoUrl || targetLanguages.length === 0) {
412 | setError(t('errors.invalidInput'));
413 | return;
414 | }
415 |
416 | // Use "DEFAULT" if sourceLanguage is null or empty (Auto Detect)
417 | const sourceLang = sourceLanguage || 'DEFAULT';
418 |
419 | // Check if all target languages that require voice selection have voices selected
420 | for (const langCode of targetLanguages) {
421 | const targetLang = languages.find(lang => lang.lang_code === langCode);
422 | if (targetLang?.need_voice_id && !selectedVoices[langCode]) {
423 | setError(t('errors.voiceRequired'));
424 | return;
425 | }
426 | }
427 |
428 | // Store lipSync value in localStorage
429 | localStorage.setItem('lipSyncSelected', lipSync.toString());
430 |
431 | // Build voices_map object
432 | const voicesMap: { [langCode: string]: { voice_id: string } } = {};
433 | for (const langCode of targetLanguages) {
434 | const voiceId = selectedVoices[langCode] || '';
435 | voicesMap[langCode] = { voice_id: voiceId };
436 | }
437 |
438 | const payload: any = {
439 | url: videoUrl,
440 | language: targetLanguages.join(','), // Comma-separated string
441 | source_language: sourceLang,
442 | lipsync: lipSync,
443 | speaker_num: speakerNum,
444 | remove_bgm: removeBgm,
445 | caption_type: captionType,
446 | dynamic_duration: dynamicDuration,
447 | webhookUrl: "https://dd9f-219-91-134-123.ngrok-free.app/api/webhook",
448 | voices_map: voicesMap,
449 | };
450 |
451 | // Add caption_url only if provided
452 | if (captionUrl.trim()) {
453 | payload.caption_url = captionUrl.trim();
454 | }
455 |
456 | setError(null);
457 | setIsTranslating(true);
458 | setShowProcessingPopup(true);
459 | setOverallProgress(0);
460 | setProcessingVideos({});
461 | setVideoModelIds({});
462 | setVideoStatuses({});
463 | setVideoProgress({});
464 |
465 | try {
466 | const response = await axios.post(
467 | '/api/open/v3/content/video/createbytranslate',
468 | payload,
469 | {
470 | headers: {
471 | ...getAuthHeaders(),
472 | 'Content-Type': 'application/json',
473 | },
474 | }
475 | );
476 |
477 | if (response.data.code === 1000) {
478 | setTranslationResult(null);
479 |
480 | // Handle the new all_results structure
481 | const allResults = response.data.all_results || [];
482 | const modelIdsMap: { [langCode: string]: string } = {};
483 | const initialStatuses: { [langCode: string]: number } = {};
484 | const initialProgress: { [langCode: string]: number } = {};
485 |
486 | // Extract model IDs and language codes from all_results
487 | allResults.forEach((result: any) => {
488 | if (result.code === 1000 && result.data) {
489 | const langCode = result.data.language;
490 | const modelId = result.data._id;
491 | if (langCode && modelId) {
492 | modelIdsMap[langCode] = modelId;
493 | initialStatuses[langCode] = result.data.video_status || 1;
494 | initialProgress[langCode] = result.data.progress || 0;
495 | }
496 | }
497 | });
498 |
499 | // If all_results is empty, fall back to main data (single language)
500 | if (Object.keys(modelIdsMap).length === 0 && response.data.data?._id) {
501 | const langCode = response.data.data.language || targetLanguages[0] || 'default';
502 | modelIdsMap[langCode] = response.data.data._id;
503 | initialStatuses[langCode] = response.data.data.video_status || 1;
504 | initialProgress[langCode] = response.data.data.progress || 0;
505 | }
506 |
507 | // Set up tracking for all languages
508 | setVideoModelIds(modelIdsMap);
509 | setVideoStatuses(initialStatuses);
510 | setVideoProgress(initialProgress);
511 |
512 | // Calculate initial overall progress
513 | const progressValues = Object.values(initialProgress);
514 | if (progressValues.length > 0) {
515 | const avgProgress = progressValues.reduce((sum, p) => sum + p, 0) / progressValues.length;
516 | setOverallProgress(Math.round(avgProgress));
517 | }
518 |
519 | // Start polling for all model IDs
520 | if (Object.keys(modelIdsMap).length > 0) {
521 | const interval = setInterval(() => {
522 | checkAllVideoStatuses();
523 | }, 3000);
524 | setPollingInterval(interval);
525 |
526 | // Also check immediately
527 | checkAllVideoStatuses();
528 | }
529 | } else {
530 | setError(response.data.msg || t('errors.translationError'));
531 | setShowProcessingPopup(false);
532 | }
533 | } catch (err: any) {
534 | setError(err.response?.data?.msg || t('errors.translationError'));
535 | setShowProcessingPopup(false);
536 | console.error(err);
537 | } finally {
538 | setIsTranslating(false);
539 | }
540 | };
541 |
542 | const isTranslateButtonDisabled = () => {
543 | if (!videoUrl || targetLanguages.length === 0) {
544 | return true;
545 | }
546 | // Check if all target languages that require voice selection have voices selected
547 | for (const langCode of targetLanguages) {
548 | const lang = languages.find(l => l.lang_code === langCode);
549 | if (lang?.need_voice_id && !selectedVoices[langCode]) {
550 | return true;
551 | }
552 | }
553 | return false;
554 | };
555 |
556 | const handleErrorPopupClose = () => {
557 | setShowErrorPopup(false);
558 | setErrorMessage('');
559 | };
560 |
561 | const handleDownload = (videoUrl: string, langCode: string) => {
562 | if (videoUrl) {
563 | // Get language name for filename
564 | const lang = languages.find(l => l.lang_code === langCode);
565 | const langName = lang?.lang_name || langCode;
566 |
567 | // Create a temporary link element
568 | const a = document.createElement('a');
569 | a.href = videoUrl;
570 | a.download = `translated-video-${langName}-${Date.now()}.mp4`; // Add language name and timestamp
571 |
572 | // This will work because the video is already loaded in the video element
573 | // and the browser has already validated the CORS policy
574 | document.body.appendChild(a);
575 | a.click();
576 | document.body.removeChild(a);
577 | }
578 | };
579 |
580 | const handleTranslateAnother = () => {
581 | localStorage.removeItem('lipSyncSelected');
582 | setVideoUrl('');
583 | setSourceLanguage(null);
584 | setTargetLanguages([]);
585 | setSelectedVoices({});
586 | setAvailableVoices({});
587 | setLipSync(false);
588 | setSpeakerNum(0);
589 | setRemoveBgm(false);
590 | setCaptionType(0);
591 | setDynamicDuration(false);
592 | setCaptionUrl('');
593 | setShowAdvancedSettings(false);
594 | setVideoPreviewError(null);
595 | setError(null);
596 | setTranslationResult(null);
597 | setProcessingVideos({});
598 | setVideoModelIds({});
599 | setVideoStatuses({});
600 | setVideoProgress({});
601 | setOverallProgress(0);
602 | setSelectedVideoLangCode(null);
603 | setShowProcessingPopup(false);
604 | setShowErrorPopup(false);
605 | setErrorMessage('');
606 | setIsTranslating(false);
607 | if (pollingInterval) {
608 | clearInterval(pollingInterval);
609 | setPollingInterval(null);
610 | }
611 | };
612 |
613 | const fetchToken = async () => {
614 | if (!clientId || !clientSecret) {
615 | setError(t('errors.tokenError'));
616 | return;
617 | }
618 |
619 | setIsTokenLoading(true);
620 | setError(null);
621 |
622 | try {
623 | const response = await axios.post(
624 | '/api/open/v3/getToken',
625 | {
626 | clientId,
627 | clientSecret
628 | },
629 | {
630 | headers: {
631 | 'Content-Type': 'application/json'
632 | }
633 | }
634 | );
635 |
636 | console.log('Token API Response:', response.data);
637 |
638 | if (response.data.code === 1000) {
639 | if (response.data.token) {
640 | setBearerToken(response.data.token);
641 | console.log('Token successfully set:', response.data.token);
642 | } else {
643 | setError('Token not found in response');
644 | console.error('Token missing from response:', response.data);
645 | }
646 | } else {
647 | setError(response.data.msg || 'Failed to fetch token');
648 | console.error('API Error:', response.data);
649 | }
650 | } catch (err: any) {
651 | console.error('Full error object:', err);
652 |
653 | if (err.response) {
654 | console.error('Error response:', {
655 | data: err.response.data,
656 | status: err.response.status,
657 | headers: err.response.headers
658 | });
659 | setError(`Error: ${err.response.data.msg || 'Server error'}`);
660 | } else if (err.request) {
661 | console.error('Error request:', err.request);
662 | setError('No response received from server');
663 | } else {
664 | console.error('Error message:', err.message);
665 | setError(`Error: ${err.message}`);
666 | }
667 | } finally {
668 | setIsTokenLoading(false);
669 | }
670 | };
671 |
672 | return (
673 |
674 |
675 |
676 |

677 |
{t('appTitle')}
678 |
679 |
680 |
681 |
682 | {!isAuthenticated() && (
683 |
684 |
685 |
691 |
697 |
698 |
699 |
700 | {authMethod === 'apiKey' && (
701 |
702 |
703 |
704 | setApiKeyInput(e.target.value)}
709 | onKeyPress={(e) => {
710 | if (e.key === 'Enter' && apiKeyInput) {
711 | setApiKey(apiKeyInput);
712 | setFetchingLanguages(true);
713 | }
714 | }}
715 | />
716 |
717 |
731 |
732 | )}
733 |
734 | {authMethod === 'credentials' && (
735 |
736 |
737 |
738 | setClientId(e.target.value)}
743 | />
744 |
745 |
746 |
747 | setClientSecret(e.target.value)}
752 | />
753 |
754 |
768 |
769 | )}
770 |
771 |
772 | {error && (
773 |
774 |
775 | {error}
776 |
777 | )}
778 |
779 | )}
780 |
781 | {isAuthenticated() && Object.keys(processingVideos).length === 0 && (
782 |
783 | {fetchingLanguages ? (
784 |
785 |
786 |
{t('loading.languages')}
787 |
788 | ) : (
789 |
790 | {/* Left Section - Inputs */}
791 |
792 | {/* Video URL Input */}
793 |
794 |
795 |
796 |
797 |
{
801 | setVideoUrl(e.target.value);
802 | setVideoPreviewError(null);
803 | }}
804 | placeholder={t('enterVideoUrl')}
805 | className="modern-input"
806 | />
807 |
808 |
809 | {/* Language Selection */}
810 |
811 |
812 |
{t('languageSelection.title')}
813 |
814 |
815 |
816 |
819 |
831 |
832 |
833 |
→
834 |
835 |
836 |
839 |
840 |
841 | {targetLanguages.map((langCode) => {
842 | const lang = languages.find(l => l.lang_code === langCode);
843 | return (
844 |
845 | {lang?.lang_name || langCode}
846 |
853 |
854 | );
855 | })}
856 |
898 |
899 |
900 |
901 |
902 |
903 |
904 | {/* Voice Selection for each target language */}
905 | {targetLanguages.map((langCode) => {
906 | const lang = languages.find(l => l.lang_code === langCode);
907 | if (!lang?.need_voice_id) return null;
908 |
909 | const voices = availableVoices[langCode] || [];
910 | const selectedVoice = selectedVoices[langCode];
911 |
912 | return (
913 |
914 |
915 |
{t('selectVoice')} - {lang.lang_name}
916 |
917 | {voices.length === 0 ? (
918 |
919 |
920 |
{t('loading.voices')}
921 |
922 | ) : (
923 |
924 | {voices.map((voice: Voice) => (
925 |
setVoiceForLanguage(langCode, voice.voice_id)}
929 | >
930 | {voice.thumbnailUrl && (
931 |

932 | )}
933 |
934 |
{voice.name}
935 |
{voice.gender}
936 | {voice.preview && (
937 |
940 | )}
941 |
942 |
943 | ))}
944 |
945 | )}
946 |
947 | );
948 | })}
949 |
950 | {/* Basic Options */}
951 |
952 |
953 |
{t('basicOptions.title')}
954 |
955 |
956 |
957 |
969 |
970 |
971 |
983 |
984 |
985 |
988 |
998 |
999 |
1000 |
1001 |
1002 | {/* Advanced Settings */}
1003 |
1004 |
1011 | {showAdvancedSettings && (
1012 |
1013 |
1014 |
1026 |
1027 |
1028 |
1031 |
1042 |
1043 |
1044 |
1047 | setCaptionUrl(e.target.value)}
1051 | placeholder="https://example.com/captions.srt"
1052 | className="modern-input"
1053 | />
1054 | {t('advancedSettings.captionFileUrlHint')}
1055 |
1056 |
1057 | )}
1058 |
1059 |
1060 | {/* Translate Button */}
1061 |
1062 |
1078 |
1079 |
1080 | {error &&
{error}
}
1081 | {translationResult}
1082 |
1083 |
1084 | {/* Right Section - Video Preview */}
1085 |
1086 |
1087 |
1088 |
1089 | {t('videoPreview.title')}
1090 |
1091 |
1092 |
1093 | {videoUrl ? (
1094 | <>
1095 |
1112 |
1113 |
1114 |
1115 | )}
1116 |
1117 | )}
1118 |
1119 | {Object.keys(processingVideos).length > 0 && (
1120 |
1121 | {(() => {
1122 | // Only show single video view if:
1123 | // 1. User explicitly selected a video from gallery, OR
1124 | // 2. There's exactly one video AND we're not in batch mode (check if we have multiple model IDs tracked)
1125 | const isBatchMode = Object.keys(videoModelIds).length > 1;
1126 | const hasSingleVideo = Object.keys(processingVideos).length === 1;
1127 | const shouldShowSingleView = selectedVideoLangCode || (!isBatchMode && hasSingleVideo && !selectedVideoLangCode);
1128 | return shouldShowSingleView;
1129 | })() ? (
1130 | // Single video view or selected video from gallery
1131 | (() => {
1132 | const langCode = selectedVideoLangCode || Object.keys(processingVideos)[0];
1133 | const videoUrl = processingVideos[langCode];
1134 | const lang = languages.find(l => l.lang_code === langCode);
1135 | const langName = lang?.lang_name || langCode;
1136 | const flagUrl = lang?.flag_url;
1137 |
1138 | return (
1139 | <>
1140 |
1141 |
{t('results.title')}
1142 |
1143 | {t('results.singleVideo')}
1144 |
1145 |
1146 |
1147 | {Object.keys(processingVideos).length > 1 && (
1148 |
1157 | )}
1158 |
1159 |
1160 |
1161 |
1162 |
1163 | {flagUrl && (
1164 |

1165 | )}
1166 |
1167 |
{langName}
1168 | {langCode.toUpperCase()}
1169 |
1170 |
1171 |
1172 |
1173 |
1174 |
1179 |
1180 |
1181 |
1182 |
1193 |
1194 |
1195 |
1196 |
1197 |
1198 |
1210 |
1211 | >
1212 | );
1213 | })()
1214 | ) : (
1215 | // Gallery view for multiple videos
1216 | <>
1217 |
1218 |
{t('results.title')}
1219 |
1220 | {(() => {
1221 | const completedCount = Object.keys(processingVideos).length;
1222 | // Count total videos excluding failed ones
1223 | const totalCount = Object.values(videoStatuses).filter(status => status !== 4).length || targetLanguages.length;
1224 | const failedCount = Object.values(videoStatuses).filter(status => status === 4).length;
1225 | const activeCount = totalCount - failedCount;
1226 |
1227 | if (totalCount > 1 && completedCount < activeCount) {
1228 | return t('results.videosReady', { completed: completedCount, total: activeCount }) + (failedCount > 0 ? ` (${failedCount} ${t('processing.statuses.failed')})` : '');
1229 | }
1230 | if (failedCount > 0 && completedCount > 0) {
1231 | return t('results.videosReadyWithFailed', { completed: completedCount, failed: failedCount });
1232 | }
1233 | return t('results.multipleVideos', { count: completedCount });
1234 | })()}
1235 |
1236 |
{t('results.galleryHint')}
1237 |
1238 |
1239 |
1240 | {Object.entries(processingVideos).map(([langCode, videoUrl]) => {
1241 | const lang = languages.find(l => l.lang_code === langCode);
1242 | const langName = lang?.lang_name || langCode;
1243 | const flagUrl = lang?.flag_url;
1244 |
1245 | return (
1246 |
setSelectedVideoLangCode(langCode)}
1250 | >
1251 |
1252 |
1270 |
1271 |
1272 |
1273 | {flagUrl && (
1274 |

1275 | )}
1276 |
1277 |
{langName}
1278 | {langCode.toUpperCase()}
1279 |
1280 |
1281 |
1282 |
1283 | );
1284 | })}
1285 |
1286 |
1287 |
1288 |
1300 |
1301 | >
1302 | )}
1303 |
1304 | )}
1305 |
1306 | {showProcessingPopup && (
1307 |
1308 |
1309 |
{t('processing.title')}
1310 |
{t('processing.message')}
1311 |
1312 | {overallProgress > 0 && (
1313 |
1314 |
1317 |
{overallProgress}%
1318 |
1319 | )}
1320 | {Object.keys(videoStatuses).length > 1 && (
1321 |
1322 | {Object.entries(videoStatuses).map(([langCode, status]) => {
1323 | const lang = languages.find(l => l.lang_code === langCode);
1324 | const langName = lang?.lang_name || langCode;
1325 | const progress = videoProgress[langCode] || 0;
1326 | const statusText = status === 1 ? t('processing.statuses.queueing') : status === 2 ? t('processing.statuses.processing') : status === 3 ? t('processing.statuses.completed') : t('processing.statuses.failed');
1327 |
1328 | return (
1329 |
1330 |
1331 | {langName}
1332 | {statusText}
1333 |
1334 | {status !== 3 && status !== 4 && (
1335 |
1338 | )}
1339 |
1340 | );
1341 | })}
1342 |
1343 | )}
1344 |
1345 |
1346 | )}
1347 |
1348 | {showErrorPopup && (
1349 |
1350 |
1351 |
{errorMessage}
1352 |
1353 |
1354 |
1355 | )}
1356 |
1357 |
1358 | );
1359 | };
1360 |
1361 | export default App;
1362 |
--------------------------------------------------------------------------------