├── 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 | AI Video Translator Logo 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 | {voice.name} 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 | {langName} 1165 | )} 1166 |
1167 |

{langName}

1168 | {langCode.toUpperCase()} 1169 |
1170 |
1171 |
1172 | 1173 |
1174 |
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 | {langName} 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 |
1315 |
1316 |
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 |
1336 |
1337 |
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 | --------------------------------------------------------------------------------