├── face_swap_frontend ├── src │ ├── vite-env.d.ts │ ├── components │ │ ├── ui │ │ │ ├── index.ts │ │ │ ├── Card.tsx │ │ │ ├── Tabs.tsx │ │ │ ├── Input.tsx │ │ │ ├── Button.tsx │ │ │ └── Modal.tsx │ │ ├── layout │ │ │ ├── Container.tsx │ │ │ └── Header.tsx │ │ └── features │ │ │ ├── StatusDisplay.tsx │ │ │ ├── ResultModal.tsx │ │ │ ├── AuthForm.tsx │ │ │ ├── ImageSwap.tsx │ │ │ └── VideoSwap.tsx │ ├── main.tsx │ ├── index.css │ ├── utils │ │ └── file.ts │ ├── types │ │ └── index.ts │ ├── hooks │ │ └── useWebSocket.ts │ ├── services │ │ └── api.ts │ ├── assets │ │ └── react.svg │ ├── App.tsx │ └── App.css ├── postcss.config.js ├── tsconfig.json ├── public │ ├── images │ │ └── 4p6vr8j7vbom4axo7k0 2.png │ └── vite.svg ├── vite.config.ts ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── package.json └── README.md ├── face_swap_webhook ├── .env.example ├── requirements.txt └── app.py ├── .gitignore └── README.md /face_swap_frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /face_swap_webhook/.env.example: -------------------------------------------------------------------------------- 1 | # API Credentials 2 | CLIENT_ID = your_client_id_here 3 | CLIENT_SECRET = your_secret_key_here -------------------------------------------------------------------------------- /face_swap_frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | 8 | -------------------------------------------------------------------------------- /face_swap_frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /face_swap_frontend/public/images/4p6vr8j7vbom4axo7k0 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKOOL-Official/akool-face-swap-demo/HEAD/face_swap_frontend/public/images/4p6vr8j7vbom4axo7k0 2.png -------------------------------------------------------------------------------- /face_swap_webhook/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 8 | requests==2.31.0 -------------------------------------------------------------------------------- /face_swap_frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel export for UI components 2 | export { Button } from './Button'; 3 | export { Input } from './Input'; 4 | export { Modal } from './Modal'; 5 | export { Tabs } from './Tabs'; 6 | export { Card } from './Card'; 7 | 8 | 9 | -------------------------------------------------------------------------------- /face_swap_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 | 12 | 13 | -------------------------------------------------------------------------------- /face_swap_frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | padding: 0; 10 | font-family: system-ui, -apple-system, sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | background: #111827; 14 | color: #f9fafb; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /.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 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | venv/ 27 | .env/ 28 | *.pyc 29 | __pycache__/ 30 | .env 31 | *.env 32 | -------------------------------------------------------------------------------- /face_swap_frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Face Swap Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /face_swap_frontend/src/utils/file.ts: -------------------------------------------------------------------------------- 1 | export const convertFileToBase64 = (file: File): Promise => { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | reader.readAsDataURL(file); 5 | reader.onload = () => { 6 | if (typeof reader.result === 'string') { 7 | resolve(reader.result); 8 | } 9 | }; 10 | reader.onerror = (error) => reject(error); 11 | }); 12 | }; 13 | 14 | export const isVideoUrl = (url: string): boolean => { 15 | return /\.(mp4|mov|avi|wmv|flv|mkv)$/i.test(url); 16 | }; 17 | 18 | 19 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/layout/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ContainerProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | } 7 | 8 | export const Container: React.FC = ({ children, className = '' }) => { 9 | return ( 10 |
11 |
12 | {children} 13 |
14 |
15 | ); 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CardProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | gradient?: boolean; 7 | } 8 | 9 | export const Card: React.FC = ({ children, className = '', gradient = false }) => { 10 | return ( 11 |
21 | {children} 22 |
23 | ); 24 | }; 25 | 26 | 27 | -------------------------------------------------------------------------------- /face_swap_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 | -------------------------------------------------------------------------------- /face_swap_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 | -------------------------------------------------------------------------------- /face_swap_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 | -------------------------------------------------------------------------------- /face_swap_frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type AuthMethod = 'token' | 'credentials'; 2 | export type AuthType = 'apikey' | 'bearer'; 3 | export type ApiVersion = 'v3' | 'v4'; 4 | export type TabType = 'image-v3' | 'image-v4' | 'video-v3'; 5 | 6 | export interface FaceSwapStatus { 7 | type: 'error' | 'status_update'; 8 | status?: number; 9 | message: string; 10 | data?: { 11 | url?: string; 12 | _id?: string; 13 | }; 14 | } 15 | 16 | export interface AuthState { 17 | token: string; 18 | authType: AuthType; 19 | isAuthenticated: boolean; 20 | } 21 | 22 | export interface FaceSwapConfig { 23 | sourceImage: string | string[]; 24 | targetImage: string; 25 | targetVideo?: string; 26 | apiVersion: ApiVersion; 27 | webhookUrl: string; 28 | faceEnhance: boolean; 29 | singleFace: boolean; 30 | } 31 | 32 | export interface FaceDetectionResult { 33 | landmarks_str: string | string[]; 34 | face_count?: number; 35 | } 36 | 37 | 38 | -------------------------------------------------------------------------------- /face_swap_frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "face_swap_frontend", 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.13.2", 14 | "react": "^18.3.1", 15 | "react-dom": "^18.3.1", 16 | "socket.io-client": "^4.8.1" 17 | }, 18 | "devDependencies": { 19 | "@eslint/js": "^9.15.0", 20 | "@tailwindcss/postcss": "^4.1.17", 21 | "@types/react": "^18.3.12", 22 | "@types/react-dom": "^18.3.1", 23 | "@vitejs/plugin-react": "^4.3.4", 24 | "autoprefixer": "^10.4.22", 25 | "eslint": "^9.15.0", 26 | "eslint-plugin-react-hooks": "^5.0.0", 27 | "eslint-plugin-react-refresh": "^0.4.14", 28 | "globals": "^15.12.0", 29 | "postcss": "^8.5.6", 30 | "tailwindcss": "^4.1.17", 31 | "typescript": "~5.6.2", 32 | "typescript-eslint": "^8.15.0", 33 | "vite": "^6.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/features/StatusDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card } from '../ui/Card'; 3 | 4 | interface StatusDisplayProps { 5 | status: string; 6 | isLoading: boolean; 7 | } 8 | 9 | export const StatusDisplay: React.FC = ({ status, isLoading }) => { 10 | if (!status && !isLoading) return null; 11 | 12 | return ( 13 | 14 |
15 | {isLoading && ( 16 |
17 | 18 | 19 | 20 | 21 |
22 | )} 23 |
24 |
Processing Status
25 |
{status}
26 |
27 |
28 |
29 | ); 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /face_swap_frontend/src/hooks/useWebSocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { io, Socket } from 'socket.io-client'; 3 | import { FaceSwapStatus } from '../types'; 4 | 5 | export const useWebSocket = (url: string) => { 6 | const [socket, setSocket] = useState(null); 7 | const [isConnected, setIsConnected] = useState(false); 8 | const [status, setStatus] = useState(null); 9 | 10 | useEffect(() => { 11 | const socketInstance = io(url, { 12 | transports: ['websocket', 'polling'], 13 | reconnection: true, 14 | reconnectionAttempts: 5, 15 | reconnectionDelay: 1000, 16 | timeout: 10000, 17 | autoConnect: true, 18 | }); 19 | 20 | socketInstance.on('connect', () => { 21 | console.log('WebSocket connected'); 22 | setIsConnected(true); 23 | }); 24 | 25 | socketInstance.on('disconnect', () => { 26 | console.log('WebSocket disconnected'); 27 | setIsConnected(false); 28 | }); 29 | 30 | socketInstance.on('faceswap_status', (message: FaceSwapStatus) => { 31 | console.log('Received status:', message); 32 | setStatus(message); 33 | }); 34 | 35 | setSocket(socketInstance); 36 | 37 | return () => { 38 | socketInstance.disconnect(); 39 | }; 40 | }, [url]); 41 | 42 | return { socket, isConnected, status }; 43 | }; 44 | 45 | 46 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Tab { 4 | id: string; 5 | label: string; 6 | description?: string; 7 | icon?: React.ReactNode; 8 | } 9 | 10 | interface TabsProps { 11 | tabs: Tab[]; 12 | activeTab: string; 13 | onChange: (tabId: string) => void; 14 | } 15 | 16 | export const Tabs: React.FC = ({ tabs, activeTab, onChange }) => { 17 | return ( 18 |
19 | {tabs.map((tab) => ( 20 | 39 | ))} 40 |
41 | ); 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface InputProps extends React.InputHTMLAttributes { 4 | label?: string; 5 | error?: string; 6 | icon?: React.ReactNode; 7 | } 8 | 9 | export const Input: React.FC = ({ 10 | label, 11 | error, 12 | icon, 13 | className = '', 14 | ...props 15 | }) => { 16 | return ( 17 |
18 | {label && ( 19 | 22 | )} 23 |
24 | {icon && ( 25 |
26 | {icon} 27 |
28 | )} 29 | 42 |
43 | {error && ( 44 |

{error}

45 | )} 46 |
47 | ); 48 | }; 49 | 50 | 51 | -------------------------------------------------------------------------------- /face_swap_frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '../ui/Button'; 3 | 4 | interface HeaderProps { 5 | onCheckBalance: () => void; 6 | isLoading?: boolean; 7 | } 8 | 9 | export const Header: React.FC = ({ onCheckBalance, isLoading }) => { 10 | return ( 11 |
12 |
13 | Face Swap AI 18 |
19 |

20 | Face Swap AI 21 |

22 |

Next-gen face swapping technology

23 |
24 |
25 | 26 | 37 |
38 | ); 39 | }; 40 | 41 | 42 | -------------------------------------------------------------------------------- /face_swap_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 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; 5 | size?: 'sm' | 'md' | 'lg'; 6 | isLoading?: boolean; 7 | children: React.ReactNode; 8 | } 9 | 10 | export const Button: React.FC = ({ 11 | variant = 'primary', 12 | size = 'md', 13 | isLoading = false, 14 | children, 15 | className = '', 16 | disabled, 17 | ...props 18 | }) => { 19 | const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed'; 20 | 21 | const variants = { 22 | primary: 'bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white shadow-lg hover:shadow-xl', 23 | secondary: 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg hover:shadow-xl', 24 | outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white', 25 | ghost: 'bg-white/10 hover:bg-white/20 text-white', 26 | }; 27 | 28 | const sizes = { 29 | sm: 'px-3 py-1.5 text-sm', 30 | md: 'px-4 py-2 text-base', 31 | lg: 'px-6 py-3 text-lg', 32 | }; 33 | 34 | return ( 35 | 52 | ); 53 | }; 54 | 55 | 56 | -------------------------------------------------------------------------------- /face_swap_frontend/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios'; 2 | import { AuthType } from '../types'; 3 | 4 | const API_BASE = 'http://localhost:3008/api/proxy'; 5 | 6 | class ApiService { 7 | private getAuthHeaders(token: string, authType: AuthType): Record { 8 | return authType === 'bearer' 9 | ? { 'Authorization': `Bearer ${token}` } 10 | : { 'x-api-key': token }; 11 | } 12 | 13 | private async request( 14 | url: string, 15 | token: string, 16 | authType: AuthType, 17 | config?: AxiosRequestConfig 18 | ): Promise { 19 | const headers = { 20 | ...this.getAuthHeaders(token, authType), 21 | 'Content-Type': 'application/json', 22 | ...config?.headers, 23 | }; 24 | 25 | const response = await axios({ url, headers, ...config }); 26 | return response.data; 27 | } 28 | 29 | async getToken(clientId: string, clientSecret: string) { 30 | return axios.post(`${API_BASE}/getToken`, { clientId, clientSecret }); 31 | } 32 | 33 | async getQuotaInfo(token: string, authType: AuthType) { 34 | return this.request(`${API_BASE}/quota/info`, token, authType, { method: 'GET' }); 35 | } 36 | 37 | async detectFace(data: Record, token: string, authType: AuthType) { 38 | return this.request(`${API_BASE}/detect`, token, authType, { 39 | method: 'POST', 40 | data, 41 | }); 42 | } 43 | 44 | async faceSwapV3Image(data: unknown, token: string, authType: AuthType) { 45 | return this.request(`${API_BASE}/faceswap/v3/image`, token, authType, { 46 | method: 'POST', 47 | data, 48 | }); 49 | } 50 | 51 | async faceSwapV4Image(data: unknown, token: string, authType: AuthType) { 52 | return this.request(`${API_BASE}/faceswap/v4/image`, token, authType, { 53 | method: 'POST', 54 | data, 55 | }); 56 | } 57 | 58 | async faceSwapV3Video(data: unknown, token: string, authType: AuthType) { 59 | return this.request(`${API_BASE}/faceswap/v3/video`, token, authType, { 60 | method: 'POST', 61 | data, 62 | }); 63 | } 64 | } 65 | 66 | export const apiService = new ApiService(); 67 | 68 | 69 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/ui/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ModalProps { 4 | isOpen: boolean; 5 | onClose: () => void; 6 | title?: string; 7 | children: React.ReactNode; 8 | size?: 'sm' | 'md' | 'lg' | 'xl'; 9 | } 10 | 11 | export const Modal: React.FC = ({ 12 | isOpen, 13 | onClose, 14 | title, 15 | children, 16 | size = 'md', 17 | }) => { 18 | if (!isOpen) return null; 19 | 20 | const sizes = { 21 | sm: 'max-w-md', 22 | md: 'max-w-lg', 23 | lg: 'max-w-2xl', 24 | xl: 'max-w-4xl', 25 | }; 26 | 27 | return ( 28 |
29 |
30 | {/* Backdrop */} 31 |
35 | 36 | {/* Modal */} 37 |
38 |
39 | {/* Close button */} 40 | 48 | 49 | {/* Title */} 50 | {title && ( 51 |
52 |

{title}

53 |
54 | )} 55 | 56 | {/* Content */} 57 |
58 | {children} 59 |
60 |
61 |
62 |
63 |
64 | ); 65 | }; 66 | 67 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/features/ResultModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal } from '../ui/Modal'; 3 | import { Button } from '../ui/Button'; 4 | import { isVideoUrl } from '../../utils/file'; 5 | 6 | interface ResultModalProps { 7 | isOpen: boolean; 8 | onClose: () => void; 9 | resultUrl: string | null; 10 | } 11 | 12 | export const ResultModal: React.FC = ({ isOpen, onClose, resultUrl }) => { 13 | if (!resultUrl) return null; 14 | 15 | const isVideo = isVideoUrl(resultUrl); 16 | 17 | const handleDownload = () => { 18 | const link = document.createElement('a'); 19 | link.href = resultUrl; 20 | link.download = isVideo ? 'face-swap-result.mp4' : 'face-swap-result.png'; 21 | link.target = '_blank'; 22 | document.body.appendChild(link); 23 | link.click(); 24 | document.body.removeChild(link); 25 | }; 26 | 27 | return ( 28 | 29 |
30 |
31 | {isVideo ? ( 32 | 41 | ) : ( 42 | Face Swap Result 47 | )} 48 |
49 | 50 |
51 | 57 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Face Swap AI Application 2 | 3 | This project consists of a Face Swap AI application with a React frontend and Flask backend. The application allows users to perform face swaps on both images and videos. 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 face_swap_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 face_swap_webhook 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:3008` 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 3008 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 | -------------------------------------------------------------------------------- /face_swap_frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/features/AuthForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button } from '../ui/Button'; 3 | import { Input } from '../ui/Input'; 4 | import { Card } from '../ui/Card'; 5 | import { AuthMethod } from '../../types'; 6 | 7 | interface AuthFormProps { 8 | onAuth: (token: string, method: AuthMethod, clientId?: string, clientSecret?: string) => void; 9 | isLoading?: boolean; 10 | } 11 | 12 | export const AuthForm: React.FC = ({ onAuth, isLoading }) => { 13 | const [authMethod, setAuthMethod] = useState('token'); 14 | const [token, setToken] = useState(''); 15 | const [clientId, setClientId] = useState(''); 16 | const [clientSecret, setClientSecret] = useState(''); 17 | 18 | const handleSubmit = (e: React.FormEvent) => { 19 | e.preventDefault(); 20 | if (authMethod === 'token') { 21 | onAuth(token, authMethod); 22 | } else { 23 | onAuth(token, authMethod, clientId, clientSecret); 24 | } 25 | }; 26 | 27 | return ( 28 |
29 | 30 |
31 | Face Swap AI 36 |

37 | Face Swap AI 38 |

39 |

Welcome to next-gen face swapping

40 |
41 | 42 | {/* Auth Method Toggle */} 43 |
44 | 54 | 64 |
65 | 66 |
67 | {authMethod === 'token' ? ( 68 | setToken(e.target.value)} 73 | required 74 | icon={ 75 | 76 | 77 | 78 | } 79 | /> 80 | ) : ( 81 | <> 82 | setClientId(e.target.value)} 87 | required 88 | icon={ 89 | 90 | 91 | 92 | } 93 | /> 94 | setClientSecret(e.target.value)} 99 | required 100 | icon={ 101 | 102 | 103 | 104 | } 105 | /> 106 | 107 | )} 108 | 109 | 112 |
113 |
114 |
115 | ); 116 | }; 117 | 118 | 119 | -------------------------------------------------------------------------------- /face_swap_webhook/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 | from engineio.async_drivers import gevent 7 | import base64 8 | import json 9 | import time 10 | import os 11 | import requests 12 | import hashlib 13 | from datetime import datetime 14 | 15 | load_dotenv() 16 | 17 | app = Flask(__name__) 18 | # Allow all origins with CORS 19 | CORS(app, resources={r"/*": {"origins": "*"}}) 20 | 21 | # Simplified SocketIO configuration 22 | socketio = SocketIO( 23 | app, 24 | cors_allowed_origins="*", 25 | async_mode='gevent', 26 | logger=True, 27 | engineio_logger=True, # Add engine IO logging 28 | ping_timeout=5000, # Increase ping timeout 29 | ping_interval=2500 # Adjust ping interval 30 | ) 31 | 32 | # Store events temporarily in memory 33 | events = [] 34 | 35 | def generate_msg_signature(client_id, timestamp, nonce, data_encrypt): 36 | """ 37 | Generate SHA1 signature for webhook verification 38 | Signature = sha1(sort(clientId, timestamp, nonce, dataEncrypt)) 39 | """ 40 | # Sort parameters and join 41 | sorted_str = ''.join(sorted([str(client_id), str(timestamp), str(nonce), str(data_encrypt)])) 42 | # Calculate SHA1 hash 43 | hash_obj = hashlib.sha1(sorted_str.encode('utf-8')) 44 | return hash_obj.hexdigest() 45 | 46 | def generate_aes_decrypt(data_encrypt, client_id, client_secret): 47 | """ 48 | Decrypt webhook data using AES-CBC with PKCS7 padding 49 | - Key: clientSecret (16, 24, or 32 bytes for AES-128/192/256) 50 | - IV: clientId (first 16 bytes, padded with spaces if needed) 51 | - Mode: CBC 52 | - Padding: PKCS7 53 | """ 54 | # Get the key and validate length 55 | aes_key = client_secret.encode('utf-8') 56 | key_len = len(aes_key) 57 | 58 | # Support AES-128 (16 bytes), AES-192 (24 bytes), or AES-256 (32 bytes) 59 | if key_len not in [16, 24, 32]: 60 | raise ValueError(f"clientSecret must be 16, 24, or 32 bytes for AES, got {key_len}") 61 | 62 | # Adjust key length if necessary (take first N bytes or pad) 63 | if key_len == 32: 64 | # AES-256 65 | aes_key = aes_key[:32] 66 | print(f"Using AES-256 (32 byte key)") 67 | elif key_len == 24: 68 | # AES-192 69 | aes_key = aes_key[:24] 70 | print(f"Using AES-192 (24 byte key)") 71 | elif key_len == 16: 72 | # AES-128 73 | aes_key = aes_key[:16] 74 | print(f"Using AES-128 (16 byte key)") 75 | elif key_len > 32: 76 | # Too long, truncate to 32 for AES-256 77 | aes_key = aes_key[:32] 78 | print(f"Key too long ({key_len} bytes), truncating to 32 for AES-256") 79 | elif key_len < 16: 80 | # Too short, this won't work 81 | raise ValueError(f"clientSecret too short: {key_len} bytes (minimum 16 required)") 82 | 83 | # IV must be exactly 16 bytes - pad with spaces (0x20) not null bytes 84 | # This matches the encryption side behavior 85 | iv = client_id.encode('utf-8') 86 | if len(iv) > 16: 87 | iv = iv[:16] 88 | elif len(iv) < 16: 89 | iv = iv + b' ' * (16 - len(iv)) # Pad with spaces, not null bytes 90 | 91 | print(f"Decryption config: key_length={len(aes_key)}, iv_length={len(iv)}") 92 | 93 | # Create cipher and decrypt 94 | cipher = AES.new(aes_key, AES.MODE_CBC, iv) 95 | encrypted_data = base64.b64decode(data_encrypt) 96 | decrypted_data = cipher.decrypt(encrypted_data) 97 | 98 | # Remove PKCS7 padding 99 | # The last byte tells us how many padding bytes there are 100 | try: 101 | padding_len = decrypted_data[-1] 102 | if padding_len > 16 or padding_len == 0: 103 | # Invalid padding 104 | raise ValueError(f"Invalid PKCS7 padding length: {padding_len}") 105 | 106 | # Verify all padding bytes are the same 107 | padding_bytes = decrypted_data[-padding_len:] 108 | if not all(b == padding_len for b in padding_bytes): 109 | raise ValueError("Invalid PKCS7 padding bytes") 110 | 111 | # Remove padding and decode 112 | return decrypted_data[:-padding_len].decode('utf-8') 113 | except (IndexError, ValueError) as e: 114 | print(f"Padding error: {e}") 115 | print(f"Decrypted data length: {len(decrypted_data)}") 116 | print(f"Last 16 bytes (hex): {decrypted_data[-16:].hex()}") 117 | raise ValueError(f"Failed to remove PKCS7 padding: {e}") 118 | 119 | @app.route('/test-app', methods=['GET']) 120 | def test_app(): 121 | """Test endpoint to verify server is running""" 122 | socketio.emit('message', {'data': 'Hello, World!'}) 123 | return jsonify({"message": "Hello, World!"}), 200 124 | 125 | @app.route('/health', methods=['GET']) 126 | def health_check(): 127 | """Health check endpoint""" 128 | return jsonify({ 129 | "status": "healthy", 130 | "service": "face-swap-webhook", 131 | "timestamp": datetime.now().isoformat() 132 | }), 200 133 | 134 | @app.route('/api/webhook', methods=['POST']) 135 | def webhook(): 136 | print("Webhook received") 137 | try: 138 | data = request.get_json() 139 | print("JSON data received:", data) 140 | 141 | # Extract webhook parameters 142 | signature = data.get('signature') 143 | encrypted_data = data.get('dataEncrypt') 144 | timestamp = data.get('timestamp') 145 | nonce = data.get('nonce') 146 | 147 | client_id = os.getenv('CLIENT_ID') 148 | client_secret = os.getenv('CLIENT_SECRET') 149 | 150 | if not all([signature, encrypted_data, timestamp, nonce, client_id, client_secret]): 151 | return jsonify({"error": "Missing required parameters"}), 400 152 | 153 | # Verify signature first 154 | calculated_signature = generate_msg_signature(client_id, timestamp, nonce, encrypted_data) 155 | print(f"Signature verification: received={signature}, calculated={calculated_signature}") 156 | 157 | if signature != calculated_signature: 158 | print("Signature verification failed!") 159 | return jsonify({"error": "Invalid signature"}), 401 160 | 161 | print("✅ Signature verified successfully") 162 | 163 | # Decrypt the data 164 | decrypted_data = generate_aes_decrypt(encrypted_data, client_id, client_secret) 165 | print("Decrypted Data:", decrypted_data) 166 | decrypted_json = json.loads(decrypted_data) 167 | 168 | # Enhanced status handling 169 | status = decrypted_json.get('status') 170 | if status is None: 171 | return jsonify({"error": "Missing status in payload"}), 400 172 | 173 | # Map status codes to meaningful messages 174 | status_messages = { 175 | 1: "Processing started", 176 | 2: "Processing in progress", 177 | 3: "Processing completed", 178 | 4: "Processing failed" 179 | } 180 | 181 | message = { 182 | 'type': 'error' if status == 4 else 'status_update', 183 | 'status': status, 184 | 'message': status_messages.get(status, "Unknown status"), 185 | 'data': decrypted_json 186 | } 187 | 188 | # Emit to all connected clients 189 | socketio.emit('faceswap_status', message) 190 | 191 | return jsonify({ 192 | "success": True, 193 | "message": "Webhook processed successfully" 194 | }), 200 195 | 196 | except Exception as e: 197 | print(f"Error processing webhook: {e}") 198 | socketio.emit('faceswap_status', { 199 | 'type': 'error', 200 | 'message': f"Error processing webhook: {str(e)}" 201 | }) 202 | return jsonify({"error": str(e)}), 400 203 | 204 | 205 | @socketio.on('connect') 206 | def handle_connect(): 207 | print("Client connected") 208 | emit('message', {'data': 'Connected to server', 'type': 'info'}) 209 | 210 | @socketio.on('disconnect') 211 | def handle_disconnect(): 212 | print("Client disconnected") 213 | 214 | # Helper function to get auth headers from request 215 | def get_forwarding_headers(): 216 | """Get authentication headers to forward to Akool API""" 217 | # Check for Bearer token first 218 | auth_header = request.headers.get('Authorization') 219 | if auth_header and auth_header.startswith('Bearer '): 220 | return {'Authorization': auth_header} 221 | 222 | # Check for x-api-key 223 | api_key = request.headers.get('x-api-key') 224 | if api_key: 225 | return {'x-api-key': api_key} 226 | 227 | return None 228 | 229 | # Proxy endpoints to avoid CORS issues 230 | @app.route('/api/proxy/quota/info', methods=['GET']) 231 | def proxy_quota_info(): 232 | """Proxy endpoint for credit info to avoid CORS""" 233 | auth_headers = get_forwarding_headers() 234 | if not auth_headers: 235 | return jsonify({"error": "Missing authentication header (x-api-key or Authorization)"}), 400 236 | 237 | try: 238 | response = requests.get( 239 | 'https://openapi.akool.com/api/open/v3/faceswap/quota/info', 240 | headers=auth_headers 241 | ) 242 | return jsonify(response.json()), response.status_code 243 | except Exception as e: 244 | return jsonify({"error": str(e)}), 500 245 | 246 | @app.route('/api/proxy/detect', methods=['POST']) 247 | def proxy_face_detect(): 248 | """Proxy endpoint for face detection to avoid CORS""" 249 | auth_headers = get_forwarding_headers() 250 | if not auth_headers: 251 | return jsonify({"error": "Missing authentication header (x-api-key or Authorization)"}), 400 252 | 253 | try: 254 | data = request.get_json() 255 | 256 | # Log request (without full base64 to avoid cluttering logs) 257 | log_data = {k: ('[base64 data]' if k == 'img' and v else v) for k, v in data.items()} 258 | print(f"Face detect request: {log_data}") 259 | 260 | headers = {**auth_headers, 'Content-Type': 'application/json'} 261 | response = requests.post( 262 | 'https://sg3.akool.com/detect', 263 | json=data, 264 | headers=headers 265 | ) 266 | 267 | print(f"Face detect response status: {response.status_code}") 268 | return jsonify(response.json()), response.status_code 269 | except Exception as e: 270 | print(f"Face detect error: {str(e)}") 271 | return jsonify({"error": str(e)}), 500 272 | 273 | @app.route('/api/proxy/faceswap/v3/image', methods=['POST']) 274 | def proxy_faceswap_v3_image(): 275 | """Proxy endpoint for v3 image face swap to avoid CORS""" 276 | auth_headers = get_forwarding_headers() 277 | if not auth_headers: 278 | return jsonify({"error": "Missing authentication header (x-api-key or Authorization)"}), 400 279 | 280 | try: 281 | data = request.get_json() 282 | headers = {**auth_headers, 'Content-Type': 'application/json'} 283 | response = requests.post( 284 | 'https://openapi.akool.com/api/open/v3/faceswap/highquality/specifyimage', 285 | json=data, 286 | headers=headers 287 | ) 288 | return jsonify(response.json()), response.status_code 289 | except Exception as e: 290 | return jsonify({"error": str(e)}), 500 291 | 292 | @app.route('/api/proxy/faceswap/v4/image', methods=['POST']) 293 | def proxy_faceswap_v4_image(): 294 | """Proxy endpoint for v4 image face swap to avoid CORS""" 295 | auth_headers = get_forwarding_headers() 296 | if not auth_headers: 297 | return jsonify({"error": "Missing authentication header (x-api-key or Authorization)"}), 400 298 | 299 | try: 300 | data = request.get_json() 301 | headers = {**auth_headers, 'Content-Type': 'application/json'} 302 | response = requests.post( 303 | 'https://openapi.akool.com/api/open/v4/faceswap/faceswapByImage', 304 | json=data, 305 | headers=headers 306 | ) 307 | return jsonify(response.json()), response.status_code 308 | except Exception as e: 309 | return jsonify({"error": str(e)}), 500 310 | 311 | @app.route('/api/proxy/faceswap/v3/video', methods=['POST']) 312 | def proxy_faceswap_v3_video(): 313 | """Proxy endpoint for v3 video face swap to avoid CORS""" 314 | auth_headers = get_forwarding_headers() 315 | if not auth_headers: 316 | return jsonify({"error": "Missing authentication header (x-api-key or Authorization)"}), 400 317 | 318 | try: 319 | data = request.get_json() 320 | headers = {**auth_headers, 'Content-Type': 'application/json'} 321 | response = requests.post( 322 | 'https://openapi.akool.com/api/open/v3/faceswap/highquality/specifyvideo', 323 | json=data, 324 | headers=headers 325 | ) 326 | return jsonify(response.json()), response.status_code 327 | except Exception as e: 328 | return jsonify({"error": str(e)}), 500 329 | 330 | @app.route('/api/proxy/getToken', methods=['POST']) 331 | def proxy_get_token(): 332 | """Proxy endpoint for getting token from credentials""" 333 | try: 334 | data = request.get_json() 335 | response = requests.post( 336 | 'https://openapi.akool.com/api/open/v3/getToken', 337 | json=data, 338 | headers={'Content-Type': 'application/json'} 339 | ) 340 | return jsonify(response.json()), response.status_code 341 | except Exception as e: 342 | return jsonify({"error": str(e)}), 500 343 | 344 | 345 | if __name__ == '__main__': 346 | # Run with debug mode 347 | socketio.run( 348 | app, 349 | host='0.0.0.0', 350 | port=3008, 351 | debug=True, 352 | allow_unsafe_werkzeug=True 353 | ) 354 | 355 | -------------------------------------------------------------------------------- /face_swap_frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Container } from './components/layout/Container'; 3 | import { Header } from './components/layout/Header'; 4 | import { Tabs } from './components/ui/Tabs'; 5 | import { Modal } from './components/ui/Modal'; 6 | import { AuthForm } from './components/features/AuthForm'; 7 | import { ImageSwap } from './components/features/ImageSwap'; 8 | import { VideoSwap } from './components/features/VideoSwap'; 9 | import { ResultModal } from './components/features/ResultModal'; 10 | import { StatusDisplay } from './components/features/StatusDisplay'; 11 | import { apiService } from './services/api'; 12 | import { useWebSocket } from './hooks/useWebSocket'; 13 | import { AuthMethod, AuthType, TabType } from './types'; 14 | 15 | function App() { 16 | // Auth State 17 | const [isAuthenticated, setIsAuthenticated] = useState(false); 18 | const [token, setToken] = useState(''); 19 | const [authType, setAuthType] = useState('apikey'); 20 | const [isAuthLoading, setIsAuthLoading] = useState(false); 21 | 22 | // UI State 23 | const [activeTab, setActiveTab] = useState('image-v3'); 24 | const [webhookUrl, setWebhookUrl] = useState('https://foot-urw-soa-medications.trycloudflare.com/api/webhook'); 25 | const [isBalanceLoading, setIsBalanceLoading] = useState(false); 26 | const [credit, setCredit] = useState(null); 27 | const [showCreditModal, setShowCreditModal] = useState(false); 28 | 29 | // Processing State 30 | const [isProcessing, setIsProcessing] = useState(false); 31 | const [statusMessage, setStatusMessage] = useState(''); 32 | const [resultUrl, setResultUrl] = useState(null); 33 | const [showResultModal, setShowResultModal] = useState(false); 34 | 35 | // WebSocket 36 | const { status: wsStatus } = useWebSocket('http://localhost:3008'); 37 | 38 | // Handle WebSocket status updates 39 | useEffect(() => { 40 | if (!wsStatus) return; 41 | 42 | setIsProcessing(wsStatus.status !== 3 && wsStatus.status !== 4); 43 | setStatusMessage(wsStatus.message); 44 | 45 | if (wsStatus.type === 'error') { 46 | alert(wsStatus.message); 47 | setIsProcessing(false); 48 | } 49 | 50 | if (wsStatus.status === 3 && wsStatus.data?.url) { 51 | setResultUrl(wsStatus.data.url); 52 | setShowResultModal(true); 53 | setIsProcessing(false); 54 | } 55 | }, [wsStatus]); 56 | 57 | // Authentication Handler 58 | const handleAuth = async ( 59 | tokenValue: string, 60 | method: AuthMethod, 61 | clientId?: string, 62 | clientSecret?: string 63 | ) => { 64 | setIsAuthLoading(true); 65 | try { 66 | if (method === 'credentials' && clientId && clientSecret) { 67 | const response = await apiService.getToken(clientId, clientSecret); 68 | setToken(response.data.token); 69 | setAuthType('bearer'); 70 | } else { 71 | setToken(tokenValue); 72 | setAuthType('apikey'); 73 | } 74 | setIsAuthenticated(true); 75 | } catch (error) { 76 | console.error('Authentication failed:', error); 77 | alert('Authentication failed. Please check your credentials.'); 78 | } finally { 79 | setIsAuthLoading(false); 80 | } 81 | }; 82 | 83 | // Check Balance Handler 84 | const handleCheckBalance = async () => { 85 | setIsBalanceLoading(true); 86 | try { 87 | const response = await apiService.getQuotaInfo(token, authType) as { data: { credit: number } }; 88 | setCredit(response.data.credit); 89 | setShowCreditModal(true); 90 | } catch (error) { 91 | console.error('Failed to fetch balance:', error); 92 | alert('Failed to fetch balance'); 93 | } finally { 94 | setIsBalanceLoading(false); 95 | } 96 | }; 97 | 98 | // Image Swap Handler 99 | const handleImageSwap = async (config: { 100 | sourceImage: string | string[]; 101 | targetImage: string; 102 | apiVersion: 'v3' | 'v4'; 103 | faceEnhance: boolean; 104 | singleFace: boolean; 105 | }) => { 106 | setIsProcessing(true); 107 | setStatusMessage('Starting face swap process...'); 108 | setResultUrl(null); 109 | 110 | try { 111 | if (config.apiVersion === 'v4') { 112 | // V4 Simplified - No face detection 113 | const sourceImagePath = Array.isArray(config.sourceImage) ? config.sourceImage[0] : config.sourceImage; 114 | const swapData = { 115 | targetImage: [{ path: config.targetImage }], 116 | sourceImage: [{ path: sourceImagePath }], 117 | model_name: 'akool_faceswap_image_hq', 118 | webhookUrl, 119 | face_enhance: config.faceEnhance, 120 | }; 121 | 122 | await apiService.faceSwapV4Image(swapData, token, authType); 123 | setStatusMessage('Request sent, waiting for processing...'); 124 | } else { 125 | // V3 - With face detection for multi-face support 126 | setStatusMessage('Detecting faces in images...'); 127 | 128 | const sourceImages = Array.isArray(config.sourceImage) ? config.sourceImage : [config.sourceImage]; 129 | 130 | // Detect faces in all source images and target image 131 | const detectionPromises = [ 132 | ...sourceImages.map(img => 133 | apiService.detectFace( 134 | { single_face: false, image_url: img }, 135 | token, 136 | authType 137 | ) 138 | ), 139 | apiService.detectFace( 140 | { single_face: false, image_url: config.targetImage }, 141 | token, 142 | authType 143 | ), 144 | ]; 145 | 146 | const detectionResults = await Promise.all(detectionPromises); 147 | 148 | // Extract landmarks for sources and target 149 | const sourceLandmarks = detectionResults.slice(0, -1).map(result => { 150 | const data = result as { landmarks_str: string | string[] }; 151 | return Array.isArray(data.landmarks_str) ? data.landmarks_str[0] : data.landmarks_str; 152 | }); 153 | 154 | const targetData = detectionResults[detectionResults.length - 1] as { landmarks_str: string | string[] }; 155 | const targetLandmarks = Array.isArray(targetData.landmarks_str) 156 | ? targetData.landmarks_str 157 | : [targetData.landmarks_str]; 158 | 159 | // Validate we have matching counts 160 | if (sourceLandmarks.length !== targetLandmarks.length) { 161 | throw new Error( 162 | `Mismatch: ${sourceLandmarks.length} source face(s) but ${targetLandmarks.length} target face(s). ` + 163 | `Please provide ${targetLandmarks.length} source image(s).` 164 | ); 165 | } 166 | 167 | setStatusMessage('Faces detected, starting swap...'); 168 | 169 | // Build arrays with one-to-one mapping 170 | const swapData = { 171 | sourceImage: sourceImages.map((path, i) => ({ 172 | path, 173 | opts: sourceLandmarks[i] 174 | })), 175 | targetImage: targetLandmarks.map((opts, i) => ({ 176 | path: config.targetImage, 177 | opts 178 | })), 179 | face_enhance: config.faceEnhance ? 1 : 0, 180 | modifyImage: config.targetImage, 181 | webhookUrl, 182 | }; 183 | 184 | await apiService.faceSwapV3Image(swapData, token, authType); 185 | setStatusMessage('Request sent, waiting for processing...'); 186 | } 187 | } catch (error) { 188 | console.error('Face swap failed:', error); 189 | setStatusMessage(error instanceof Error ? error.message : 'Face swap failed'); 190 | setIsProcessing(false); 191 | } 192 | }; 193 | 194 | // Video Swap Handler with Multi-Face Support 195 | const handleVideoSwap = async (config: { 196 | sourceImages: string[]; 197 | targetVideo: string; 198 | detectedTargetFaces: Array<{ path: string; opts: string }>; 199 | faceEnhance: boolean; 200 | }) => { 201 | setIsProcessing(true); 202 | setStatusMessage('Starting video face swap...'); 203 | setResultUrl(null); 204 | 205 | try { 206 | setStatusMessage('Detecting faces in source images...'); 207 | 208 | // Detect faces in all source images to get their landmarks 209 | const sourceDetections = await Promise.all( 210 | config.sourceImages.map(img => 211 | apiService.detectFace( 212 | { single_face: false, image_url: img }, 213 | token, 214 | authType 215 | ) 216 | ) 217 | ); 218 | 219 | // Extract landmarks for each source (first 4 points only) 220 | const sourceLandmarks = sourceDetections.map(result => { 221 | const data = result as { landmarks_str: string | string[] }; 222 | const landmarks = Array.isArray(data.landmarks_str) ? data.landmarks_str[0] : data.landmarks_str; 223 | return landmarks.split(':').slice(0, 4).join(':'); 224 | }); 225 | 226 | // Validate equal array lengths 227 | if (sourceLandmarks.length !== config.detectedTargetFaces.length) { 228 | throw new Error( 229 | `Array length mismatch: ${sourceLandmarks.length} source face(s) but ${config.detectedTargetFaces.length} target face(s). ` + 230 | `Must be equal for video face swap.` 231 | ); 232 | } 233 | 234 | setStatusMessage('Faces detected, processing video (this may take several minutes)...'); 235 | 236 | // Build payload with matching arrays (CRITICAL: opts must be strings, not arrays) 237 | const swapData = { 238 | sourceImage: config.sourceImages.map((path, i) => ({ 239 | path, 240 | opts: sourceLandmarks[i] // String: "x1,y1:x2,y2:x3,y3:x4,y4" 241 | })), 242 | targetImage: config.detectedTargetFaces.map(face => ({ 243 | path: face.path, 244 | opts: face.opts // String: "x1,y1:x2,y2:x3,y3:x4,y4" 245 | })), 246 | face_enhance: config.faceEnhance ? 1 : 0, 247 | modifyVideo: config.targetVideo, // Full video URL 248 | webhookUrl, 249 | }; 250 | 251 | // Validate opts are strings (prevent error 1003) 252 | const invalidOpts = [...swapData.sourceImage, ...swapData.targetImage].find( 253 | item => typeof item.opts !== 'string' 254 | ); 255 | if (invalidOpts) { 256 | throw new Error('opts must be string, not array (error 1003 prevention)'); 257 | } 258 | 259 | await apiService.faceSwapV3Video(swapData, token, authType); 260 | setStatusMessage('Video processing started successfully. Waiting for webhook response...'); 261 | } catch (error) { 262 | console.error('Video swap failed:', error); 263 | const errorMsg = error instanceof Error ? error.message : 'Video swap failed'; 264 | setStatusMessage(errorMsg); 265 | setIsProcessing(false); 266 | 267 | // Show helpful error for common issues 268 | if (errorMsg.includes('1003')) { 269 | alert('Error 1003: opts must be a string. This is a payload formatting issue.'); 270 | } 271 | } 272 | }; 273 | 274 | // Tabs configuration 275 | const tabs = [ 276 | { 277 | id: 'image-v3' as TabType, 278 | label: 'Image v3 High Quality', 279 | description: 'face detection • multi-face', 280 | }, 281 | { 282 | id: 'image-v4' as TabType, 283 | label: 'Image v4 Simplified', 284 | description: 'no detection • single-face', 285 | }, 286 | { 287 | id: 'video-v3' as TabType, 288 | label: 'Video v3', 289 | description: 'face detection • multi-face', 290 | }, 291 | ]; 292 | 293 | if (!isAuthenticated) { 294 | return ; 295 | } 296 | 297 | return ( 298 | 299 |
300 | 301 |
302 | setActiveTab(id as TabType)} /> 303 | 304 | {activeTab === 'image-v3' ? ( 305 | 314 | ) : activeTab === 'image-v4' ? ( 315 | 324 | ) : ( 325 | 333 | )} 334 | 335 | 336 |
337 | 338 | {/* Credit Balance Modal */} 339 | setShowCreditModal(false)} 342 | title="Credit Balance" 343 | > 344 |
345 |
346 | {credit?.toFixed(2)} 347 |
348 |
Available Credits
349 |
350 |
351 | 352 | {/* Result Modal */} 353 | setShowResultModal(false)} 356 | resultUrl={resultUrl} 357 | /> 358 | 359 | ); 360 | } 361 | 362 | export default App; 363 | 364 | 365 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/features/ImageSwap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import { Button } from '../ui/Button'; 3 | import { Input } from '../ui/Input'; 4 | import { Card } from '../ui/Card'; 5 | import { ApiVersion } from '../../types'; 6 | import { apiService } from '../../services/api'; 7 | 8 | interface ImageSwapProps { 9 | apiVersion: ApiVersion; 10 | onSwap: (config: { 11 | sourceImage: string | string[]; 12 | targetImage: string; 13 | apiVersion: ApiVersion; 14 | faceEnhance: boolean; 15 | singleFace: boolean; 16 | }) => void; 17 | webhookUrl: string; 18 | onWebhookChange: (url: string) => void; 19 | isLoading?: boolean; 20 | token?: string; 21 | authType?: 'apikey' | 'bearer'; 22 | } 23 | 24 | export const ImageSwap: React.FC = ({ 25 | apiVersion, 26 | onSwap, 27 | webhookUrl, 28 | onWebhookChange, 29 | isLoading, 30 | token = '', 31 | authType = 'apikey', 32 | }) => { 33 | // V4: Single source image (original behavior) 34 | const [sourceImage, setSourceImage] = useState('https://d3fulx9g4ogwhk.cloudfront.net/canva_backend/d53e6f0c-3889-46e4-b8be-dd93d9dfe596.png'); 35 | 36 | // V3: Multiple source images (dynamic based on detected faces) 37 | const [sourceImages, setSourceImages] = useState([ 38 | 'https://d3fulx9g4ogwhk.cloudfront.net/canva_backend/d53e6f0c-3889-46e4-b8be-dd93d9dfe596.png' 39 | ]); 40 | 41 | const [targetImage, setTargetImage] = useState('https://d3fulx9g4ogwhk.cloudfront.net/canva_backend/6be5eefe-ff18-4165-aa52-6dbcdf81ef75.jpeg'); 42 | const [faceEnhance, setFaceEnhance] = useState(false); 43 | const [singleFace, setSingleFace] = useState(false); // Default to multi-face for V3 44 | 45 | // Face detection state 46 | const [detectedFaces, setDetectedFaces] = useState(1); 47 | const [isDetecting, setIsDetecting] = useState(false); 48 | const [detectionError, setDetectionError] = useState(''); 49 | 50 | // Debounced face detection for V3 when target image changes 51 | const detectFacesInTarget = useCallback(async (imageUrl: string) => { 52 | if (apiVersion !== 'v3' || !imageUrl || !token) return; 53 | 54 | setIsDetecting(true); 55 | setDetectionError(''); 56 | 57 | try { 58 | const response = await apiService.detectFace( 59 | { single_face: false, image_url: imageUrl }, 60 | token, 61 | authType 62 | ) as { landmarks_str: string | string[] }; 63 | 64 | const landmarksArray = Array.isArray(response.landmarks_str) 65 | ? response.landmarks_str 66 | : [response.landmarks_str]; 67 | 68 | const faceCount = landmarksArray.filter(l => l && l.trim()).length; 69 | 70 | if (faceCount === 0) { 71 | setDetectionError('No faces detected in target image. Please try another image.'); 72 | setDetectedFaces(1); 73 | setSourceImages(['']); 74 | } else if (faceCount > 8) { 75 | setDetectionError(`Detected ${faceCount} faces. API supports max 8. Using first 8.`); 76 | setDetectedFaces(8); 77 | setSourceImages(Array(8).fill('')); 78 | } else { 79 | setDetectedFaces(faceCount); 80 | // Preserve existing source URLs if available, fill rest with empty strings 81 | setSourceImages(prev => { 82 | const newSources = Array(faceCount).fill(''); 83 | for (let i = 0; i < Math.min(prev.length, faceCount); i++) { 84 | newSources[i] = prev[i] || ''; 85 | } 86 | return newSources; 87 | }); 88 | } 89 | } catch (error) { 90 | console.error('Face detection failed:', error); 91 | setDetectionError('Face detection failed. Using single face mode.'); 92 | setDetectedFaces(1); 93 | setSourceImages(['']); 94 | } finally { 95 | setIsDetecting(false); 96 | } 97 | }, [apiVersion, token, authType]); 98 | 99 | // Trigger face detection when target image changes (V3 only) 100 | useEffect(() => { 101 | let timeoutId: number; 102 | 103 | if (apiVersion === 'v3' && targetImage && token) { 104 | // Debounce by 800ms to avoid too many API calls while typing 105 | timeoutId = window.setTimeout(() => { 106 | detectFacesInTarget(targetImage); 107 | }, 800); 108 | } 109 | 110 | return () => window.clearTimeout(timeoutId); 111 | }, [targetImage, apiVersion, token, detectFacesInTarget]); 112 | 113 | const handleSubmit = (e: React.FormEvent) => { 114 | e.preventDefault(); 115 | 116 | if (apiVersion === 'v3') { 117 | // V3: Use array of source images 118 | onSwap({ 119 | sourceImage: sourceImages, 120 | targetImage, 121 | apiVersion, 122 | faceEnhance, 123 | singleFace 124 | }); 125 | } else { 126 | // V4: Use single source image 127 | onSwap({ 128 | sourceImage, 129 | targetImage, 130 | apiVersion, 131 | faceEnhance, 132 | singleFace 133 | }); 134 | } 135 | }; 136 | 137 | const updateSourceImage = (index: number, value: string) => { 138 | setSourceImages(prev => { 139 | const newSources = [...prev]; 140 | newSources[index] = value; 141 | return newSources; 142 | }); 143 | }; 144 | 145 | return ( 146 |
147 |
148 | {/* Webhook URL */} 149 | onWebhookChange(e.target.value)} 154 | placeholder="https://your-webhook-url.com/webhook" 155 | icon={ 156 | 157 | 158 | 159 | } 160 | /> 161 | 162 | {/* Target Image Input */} 163 | 164 |
165 |
166 | setTargetImage(e.target.value)} 171 | placeholder="https://example.com/target.jpg" 172 | required 173 | /> 174 | {apiVersion === 'v3' && ( 175 |
176 | {isDetecting && ( 177 |

178 | 179 | 180 | 181 | 182 | Detecting faces... 183 |

184 | )} 185 | {!isDetecting && detectedFaces > 0 && !detectionError && ( 186 |

187 | 188 | 189 | 190 | Detected {detectedFaces} face{detectedFaces > 1 ? 's' : ''} - provide {detectedFaces} source image{detectedFaces > 1 ? 's' : ''} below 191 |

192 | )} 193 | {detectionError && ( 194 |

195 | 196 | 197 | 198 | {detectionError} 199 |

200 | )} 201 |
202 | )} 203 |
204 | {targetImage && ( 205 |
206 |
207 | Target preview { 212 | (e.target as HTMLImageElement).style.display = 'none'; 213 | (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); 214 | }} 215 | onLoad={(e) => { 216 | (e.target as HTMLImageElement).style.display = 'block'; 217 | (e.target as HTMLImageElement).nextElementSibling?.classList.add('hidden'); 218 | }} 219 | /> 220 |
221 | 222 | 223 | 224 |
225 |
226 |
227 | )} 228 |
229 |
230 | 231 | {/* Source Images - Dynamic for V3, Single for V4 */} 232 | 233 |
234 |

235 | 236 | 237 | 238 | Source {apiVersion === 'v3' && detectedFaces > 1 ? 'Faces' : 'Face'} 239 | {apiVersion === 'v3' && ` (${detectedFaces} required)`} 240 |

241 |

242 | {apiVersion === 'v3' 243 | ? 'Provide pre-cropped single-face images for each detected target face' 244 | : 'Single face that will be swapped into the target'} 245 |

246 |
247 | 248 |
249 | {apiVersion === 'v3' ? ( 250 | // V3: Multiple source inputs based on detected faces 251 | sourceImages.map((source, index) => ( 252 |
253 |
254 | 1 ? ` → Target Face ${index + 1}` : ''}`} 256 | type="url" 257 | value={source} 258 | onChange={(e) => updateSourceImage(index, e.target.value)} 259 | placeholder={`https://example.com/source-${index + 1}.jpg (pre-cropped face)`} 260 | required 261 | /> 262 |
263 | {source && ( 264 |
265 |
266 | {`Source { 271 | (e.target as HTMLImageElement).style.display = 'none'; 272 | (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); 273 | }} 274 | onLoad={(e) => { 275 | (e.target as HTMLImageElement).style.display = 'block'; 276 | (e.target as HTMLImageElement).nextElementSibling?.classList.add('hidden'); 277 | }} 278 | /> 279 |
280 | 281 | 282 | 283 |
284 |
285 |
286 | )} 287 |
288 | )) 289 | ) : ( 290 | // V4: Single source input 291 |
292 |
293 | setSourceImage(e.target.value)} 298 | placeholder="https://example.com/source.jpg" 299 | required 300 | /> 301 |
302 | {sourceImage && ( 303 |
304 |
305 | Source preview { 310 | (e.target as HTMLImageElement).style.display = 'none'; 311 | (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); 312 | }} 313 | onLoad={(e) => { 314 | (e.target as HTMLImageElement).style.display = 'block'; 315 | (e.target as HTMLImageElement).nextElementSibling?.classList.add('hidden'); 316 | }} 317 | /> 318 |
319 | 320 | 321 | 322 |
323 |
324 |
325 | )} 326 |
327 | )} 328 |
329 |
330 | 331 | {/* Options */} 332 | 333 |
334 | {apiVersion === 'v3' && ( 335 | 346 | )} 347 | 358 |
359 |
360 | 361 | {/* Submit Button */} 362 | 378 |
379 |
380 | ); 381 | }; 382 | 383 | -------------------------------------------------------------------------------- /face_swap_frontend/src/components/features/VideoSwap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import { Button } from '../ui/Button'; 3 | import { Input } from '../ui/Input'; 4 | import { Card } from '../ui/Card'; 5 | import { apiService } from '../../services/api'; 6 | 7 | interface DetectedFace { 8 | path: string; 9 | opts: string; 10 | } 11 | 12 | interface VideoSwapProps { 13 | onSwap: (config: { 14 | sourceImages: string[]; 15 | targetVideo: string; 16 | detectedTargetFaces: DetectedFace[]; 17 | faceEnhance: boolean; 18 | }) => void; 19 | webhookUrl: string; 20 | onWebhookChange: (url: string) => void; 21 | isLoading?: boolean; 22 | token?: string; 23 | authType?: 'apikey' | 'bearer'; 24 | } 25 | 26 | export const VideoSwap: React.FC = ({ 27 | onSwap, 28 | webhookUrl, 29 | onWebhookChange, 30 | isLoading, 31 | token = '', 32 | authType = 'apikey', 33 | }) => { 34 | // Source images - dynamic array based on detected faces 35 | const [sourceImages, setSourceImages] = useState([ 36 | 'https://d21ksh0k4smeql.cloudfront.net/crop_1705475757658-3362-0-1705475757797-3713.png' 37 | ]); 38 | 39 | // Target video and reference image 40 | const [targetVideo, setTargetVideo] = useState('https://d21ksh0k4smeql.cloudfront.net/avatar_01-1705479314627-0092.mp4'); 41 | const [targetReferenceImage, setTargetReferenceImage] = useState('https://d21ksh0k4smeql.cloudfront.net/crop_1705479323786-0321-0-1705479323896-7695.png'); 42 | 43 | const [faceEnhance, setFaceEnhance] = useState(true); 44 | 45 | // Face detection state for video 46 | const [detectedTargetFaces, setDetectedTargetFaces] = useState([]); 47 | const [detectedFaceCount, setDetectedFaceCount] = useState(1); 48 | const [isDetectingVideo, setIsDetectingVideo] = useState(false); 49 | const [videoDetectionError, setVideoDetectionError] = useState(''); 50 | 51 | // Detect faces in target reference image (represents video faces) 52 | const detectVideoFaces = useCallback(async (imageUrl: string) => { 53 | if (!imageUrl || !token) return; 54 | 55 | setIsDetectingVideo(true); 56 | setVideoDetectionError(''); 57 | 58 | try { 59 | const response = await apiService.detectFace( 60 | { single_face: false, image_url: imageUrl }, 61 | token, 62 | authType 63 | ) as { landmarks_str: string | string[] }; 64 | 65 | const landmarksArray = Array.isArray(response.landmarks_str) 66 | ? response.landmarks_str 67 | : [response.landmarks_str]; 68 | 69 | const faceCount = landmarksArray.filter(l => l && l.trim()).length; 70 | 71 | if (faceCount === 0) { 72 | setVideoDetectionError('No faces detected in reference image. Please try another frame from the video.'); 73 | setDetectedFaceCount(1); 74 | setDetectedTargetFaces([]); 75 | setSourceImages(['']); 76 | } else if (faceCount > 8) { 77 | setVideoDetectionError(`Detected ${faceCount} faces. API supports max 8. Using first 8.`); 78 | const limitedLandmarks = landmarksArray.slice(0, 8); 79 | setDetectedFaceCount(8); 80 | setDetectedTargetFaces(limitedLandmarks.map(opts => ({ 81 | path: imageUrl, 82 | opts: opts.split(':').slice(0, 4).join(':') // First 4 points only 83 | }))); 84 | setSourceImages(Array(8).fill('')); 85 | } else { 86 | setDetectedFaceCount(faceCount); 87 | setDetectedTargetFaces(landmarksArray.map(opts => ({ 88 | path: imageUrl, 89 | opts: opts.split(':').slice(0, 4).join(':') // First 4 points only 90 | }))); 91 | // Preserve existing source URLs if available 92 | setSourceImages(prev => { 93 | const newSources = Array(faceCount).fill(''); 94 | for (let i = 0; i < Math.min(prev.length, faceCount); i++) { 95 | newSources[i] = prev[i] || ''; 96 | } 97 | return newSources; 98 | }); 99 | } 100 | } catch (error) { 101 | console.error('Video face detection failed:', error); 102 | setVideoDetectionError('Face detection failed. Please check the reference image or try again.'); 103 | setDetectedFaceCount(1); 104 | setDetectedTargetFaces([]); 105 | setSourceImages(['']); 106 | } finally { 107 | setIsDetectingVideo(false); 108 | } 109 | }, [token, authType]); 110 | 111 | // Trigger face detection when target reference image changes 112 | useEffect(() => { 113 | let timeoutId: number; 114 | 115 | if (targetReferenceImage && token) { 116 | // Debounce by 800ms 117 | timeoutId = window.setTimeout(() => { 118 | detectVideoFaces(targetReferenceImage); 119 | }, 800); 120 | } 121 | 122 | return () => window.clearTimeout(timeoutId); 123 | }, [targetReferenceImage, token, detectVideoFaces]); 124 | 125 | const handleSubmit = (e: React.FormEvent) => { 126 | e.preventDefault(); 127 | 128 | // Validate all source images are filled 129 | if (sourceImages.some(s => !s.trim())) { 130 | alert(`Please provide all ${detectedFaceCount} source face images`); 131 | return; 132 | } 133 | 134 | // Validate we have detected faces 135 | if (detectedTargetFaces.length === 0) { 136 | alert('Please wait for face detection to complete or provide a valid reference image'); 137 | return; 138 | } 139 | 140 | onSwap({ 141 | sourceImages, 142 | targetVideo, 143 | detectedTargetFaces, 144 | faceEnhance 145 | }); 146 | }; 147 | 148 | const updateSourceImage = (index: number, value: string) => { 149 | setSourceImages(prev => { 150 | const newSources = [...prev]; 151 | newSources[index] = value; 152 | return newSources; 153 | }); 154 | }; 155 | 156 | return ( 157 |
158 |
159 | {/* Webhook URL */} 160 | onWebhookChange(e.target.value)} 165 | placeholder="https://your-webhook-url.com/webhook" 166 | icon={ 167 | 168 | 169 | 170 | } 171 | /> 172 | 173 | {/* Target Reference Image for Face Detection */} 174 | 175 |
176 |
177 | setTargetReferenceImage(e.target.value)} 182 | placeholder="https://example.com/video-frame.jpg" 183 | required 184 | /> 185 |
186 | {isDetectingVideo && ( 187 |

188 | 189 | 190 | 191 | 192 | Detecting faces in video frame... 193 |

194 | )} 195 | {!isDetectingVideo && detectedFaceCount > 0 && !videoDetectionError && ( 196 |

197 | 198 | 199 | 200 | Detected {detectedFaceCount} face{detectedFaceCount > 1 ? 's' : ''} in video - provide {detectedFaceCount} source image{detectedFaceCount > 1 ? 's' : ''} below 201 |

202 | )} 203 | {videoDetectionError && ( 204 |

205 | 206 | 207 | 208 | {videoDetectionError} 209 |

210 | )} 211 |
212 |
213 | {targetReferenceImage && ( 214 |
215 |
216 | Reference preview { 221 | (e.target as HTMLImageElement).style.display = 'none'; 222 | (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); 223 | }} 224 | onLoad={(e) => { 225 | (e.target as HTMLImageElement).style.display = 'block'; 226 | (e.target as HTMLImageElement).nextElementSibling?.classList.add('hidden'); 227 | }} 228 | /> 229 |
230 | 231 | 232 | 233 |
234 |
235 |
236 | )} 237 |
238 |
239 | 240 | {/* Source Images - Dynamic based on detected faces */} 241 | 242 |
243 |

244 | 245 | 246 | 247 | Source {detectedFaceCount > 1 ? 'Faces' : 'Face'} ({detectedFaceCount} required) 248 |

249 |

250 | Provide pre-cropped single-face images for each detected face in the video 251 |

252 |
253 | 254 |
255 | {sourceImages.map((source, index) => ( 256 |
257 |
258 | 1 ? ` → Video Face ${index + 1}` : ''}`} 260 | type="url" 261 | value={source} 262 | onChange={(e) => updateSourceImage(index, e.target.value)} 263 | placeholder={`https://example.com/source-${index + 1}.jpg (pre-cropped face)`} 264 | required 265 | /> 266 |
267 | {source && ( 268 |
269 |
270 | {`Source { 275 | (e.target as HTMLImageElement).style.display = 'none'; 276 | (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); 277 | }} 278 | onLoad={(e) => { 279 | (e.target as HTMLImageElement).style.display = 'block'; 280 | (e.target as HTMLImageElement).nextElementSibling?.classList.add('hidden'); 281 | }} 282 | /> 283 |
284 | 285 | 286 | 287 |
288 |
289 |
290 | )} 291 |
292 | ))} 293 |
294 |
295 | 296 | {/* Video Input with Preview */} 297 |
298 |
299 | 300 | 301 | 302 | Target Video 303 |
304 | setTargetVideo(e.target.value)} 308 | placeholder="https://example.com/video.mp4" 309 | required 310 | /> 311 | {targetVideo && ( 312 | 313 |
Preview:
314 |
315 |
316 | 331 |
332 |
333 | 334 | 335 | 336 | Failed to load video 337 |
338 |
339 |
340 |
341 |
342 | )} 343 |
344 | 345 | {/* Face Enhancement Toggle */} 346 | 347 | 361 | 362 | 363 | {/* Submit Button */} 364 | 384 |
385 |
386 | ); 387 | }; 388 | 389 | -------------------------------------------------------------------------------- /face_swap_frontend/src/App.css: -------------------------------------------------------------------------------- 1 | /* Reset default margins and padding */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | /* Reset root styles */ 9 | #root { 10 | width: 100%; 11 | min-height: 100vh; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | body { 17 | margin: 0; 18 | padding: 0; 19 | width: 100%; 20 | min-height: 100vh; 21 | } 22 | 23 | .logo { 24 | width: clamp(6rem, 12vw, 8rem); 25 | height: auto; 26 | filter: drop-shadow(0 0 10px rgba(97, 218, 251, 0.5)); 27 | } 28 | 29 | @keyframes logo-spin { 30 | from { 31 | transform: rotate(0deg); 32 | } 33 | to { 34 | transform: rotate(360deg); 35 | } 36 | } 37 | 38 | @media (prefers-reduced-motion: no-preference) { 39 | a:nth-of-type(2) .logo { 40 | animation: logo-spin infinite 20s linear; 41 | } 42 | } 43 | 44 | .card { 45 | padding: 2em; 46 | } 47 | 48 | .read-the-docs { 49 | color: #888; 50 | } 51 | 52 | .welcome-container { 53 | min-height: 100vh; 54 | width: 100vw; 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); 59 | margin: 0; 60 | padding: 0; 61 | position: absolute; 62 | top: 0; 63 | left: 0; 64 | } 65 | 66 | .welcome-content { 67 | padding: clamp(2rem, 6vw, 4rem); 68 | border-radius: clamp(0.5rem, 2vw, 1rem); 69 | background: rgba(255, 255, 255, 0.05); 70 | backdrop-filter: blur(10px); 71 | box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); 72 | width: 100%; 73 | max-width: 800px; 74 | box-sizing: border-box; 75 | margin: clamp(1rem, 3vw, 3rem); 76 | } 77 | 78 | .title-container { 79 | display: flex; 80 | flex-direction: column; 81 | align-items: center; 82 | gap: 0.5rem; 83 | margin-bottom: clamp(1rem, 3vw, 1.5rem); 84 | } 85 | 86 | .title { 87 | font-size: clamp(2.5rem, 6vw, 4rem); 88 | text-align: center; 89 | background: linear-gradient(45deg, #646cff, #61dafb); 90 | -webkit-background-clip: text; 91 | -webkit-text-fill-color: transparent; 92 | } 93 | 94 | .subtitle { 95 | font-size: clamp(1rem, 2vw, 1.2rem); 96 | color: #888; 97 | margin-bottom: clamp(1.5rem, 4vw, 3rem); 98 | text-align: center; 99 | } 100 | 101 | .token-form { 102 | display: flex; 103 | flex-direction: column; 104 | gap: 1rem; 105 | width: 100%; 106 | max-width: 500px; 107 | margin: 0 auto; 108 | } 109 | 110 | .token-input { 111 | width: 100%; 112 | padding: 1rem; 113 | border-radius: 0.5rem; 114 | border: 1px solid rgba(255, 255, 255, 0.1); 115 | background: rgba(255, 255, 255, 0.05); 116 | color: white; 117 | font-size: 1rem; 118 | } 119 | 120 | .token-input::placeholder { 121 | color: rgba(255, 255, 255, 0.5); 122 | } 123 | 124 | .token-input:focus { 125 | outline: none; 126 | border-color: #646cff; 127 | box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2); 128 | } 129 | 130 | .submit-button { 131 | padding: 0.8rem 1.5rem; 132 | border-radius: 0.5rem; 133 | border: none; 134 | background: linear-gradient(45deg, #646cff, #61dafb); 135 | color: white; 136 | font-size: 1rem; 137 | cursor: pointer; 138 | transition: all 0.3s ease; 139 | } 140 | 141 | .submit-button:hover { 142 | transform: translateY(-2px); 143 | box-shadow: 0 5px 15px rgba(97, 218, 251, 0.2); 144 | } 145 | 146 | @keyframes fadeIn { 147 | from { 148 | opacity: 0; 149 | transform: translateY(20px); 150 | } 151 | to { 152 | opacity: 1; 153 | transform: translateY(0); 154 | } 155 | } 156 | 157 | @keyframes fadeOut { 158 | from { 159 | opacity: 1; 160 | transform: translateY(0); 161 | } 162 | to { 163 | opacity: 0; 164 | transform: translateY(-20px); 165 | } 166 | } 167 | 168 | .fade-in { 169 | animation: fadeIn 0.8s ease-out forwards; 170 | } 171 | 172 | .fade-out { 173 | animation: fadeOut 0.8s ease-out forwards; 174 | } 175 | 176 | /* Media Queries for different screen sizes */ 177 | @media (max-width: 768px) { 178 | .welcome-content { 179 | width: 95%; 180 | } 181 | } 182 | 183 | @media (max-width: 480px) { 184 | .welcome-content { 185 | padding: 1.2rem; 186 | } 187 | 188 | .token-form { 189 | gap: 0.8rem; 190 | } 191 | } 192 | 193 | .app-container { 194 | min-height: 100vh; 195 | width: 100vw; 196 | display: flex; 197 | justify-content: center; 198 | align-items: center; 199 | background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); 200 | position: relative; 201 | } 202 | 203 | .main-container { 204 | width: 100%; 205 | max-width: 960px; 206 | padding: 2.5rem; 207 | background: rgba(255, 255, 255, 0.05); 208 | backdrop-filter: blur(10px); 209 | border-radius: 1rem; 210 | box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); 211 | position: relative; 212 | } 213 | 214 | .balance-button { 215 | position: absolute; 216 | top: 1rem; 217 | right: 1rem; 218 | padding: 0.8rem 1.2rem; 219 | border-radius: 0.5rem; 220 | border: none; 221 | background: linear-gradient(45deg, #2a2a2a, #1a1a1a); 222 | color: white; 223 | font-size: 1rem; 224 | cursor: pointer; 225 | transition: all 0.3s ease; 226 | } 227 | 228 | .balance-button:hover { 229 | transform: translateY(-2px); 230 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); 231 | } 232 | 233 | .tabs { 234 | display: flex; 235 | justify-content: center; 236 | margin: 2rem 0; 237 | background: rgba(26, 26, 26, 0.5); 238 | padding: 0.5rem; 239 | border-radius: 0.8rem; 240 | width: fit-content; 241 | margin: 2rem auto; 242 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 243 | } 244 | 245 | .tab { 246 | padding: 1rem 2rem; 247 | border: none; 248 | background: transparent; 249 | color: #888; 250 | font-size: 1rem; 251 | cursor: pointer; 252 | transition: all 0.3s ease; 253 | position: relative; 254 | border-radius: 0.5rem; 255 | margin: 0; 256 | } 257 | 258 | .tab.active { 259 | background: linear-gradient(45deg, #646cff, #61dafb); 260 | color: white; 261 | box-shadow: 0 4px 15px rgba(97, 218, 251, 0.2); 262 | } 263 | 264 | .tab:not(.active):hover { 265 | color: white; 266 | background: rgba(255, 255, 255, 0.1); 267 | } 268 | 269 | .tab:first-child { 270 | border-right: 1px solid rgba(255, 255, 255, 0.1); 271 | } 272 | 273 | .tab-content { 274 | text-align: center; 275 | color: white; 276 | font-size: 1.2rem; 277 | animation: fadeIn 0.8s ease-out forwards; 278 | margin-top: 2rem; 279 | } 280 | 281 | .popup { 282 | position: fixed; 283 | top: 0; 284 | left: 0; 285 | width: 100vw; 286 | height: 100vh; 287 | background: rgba(0, 0, 0, 0.7); 288 | display: flex; 289 | justify-content: center; 290 | align-items: center; 291 | animation: fadeIn 0.3s ease-out forwards; 292 | } 293 | 294 | .popup-content { 295 | background: #1a1a1a; 296 | padding: 3rem; 297 | border-radius: 1rem; 298 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); 299 | text-align: center; 300 | color: white; 301 | max-width: 500px; 302 | width: 90%; 303 | } 304 | 305 | .popup-content p { 306 | font-size: 1.5rem; 307 | font-weight: bold; 308 | color: #ffffff; 309 | margin-bottom: 2rem; 310 | } 311 | 312 | .credit-balance { 313 | font-size: 2rem; 314 | color: #ffffff; 315 | margin-bottom: 2rem; 316 | } 317 | 318 | .image-swap-form { 319 | display: flex; 320 | flex-direction: column; 321 | gap: 2rem; 322 | padding: 2rem; 323 | animation: fadeIn 0.5s ease-out; 324 | } 325 | 326 | .image-input-group { 327 | display: flex; 328 | flex-direction: column; 329 | gap: 1rem; 330 | } 331 | 332 | .image-input-group h3 { 333 | color: white; 334 | font-size: 1.2rem; 335 | } 336 | 337 | .input-toggle { 338 | display: flex; 339 | gap: 1rem; 340 | margin-bottom: 1rem; 341 | } 342 | 343 | .toggle-btn { 344 | padding: 0.5rem 1rem; 345 | border: none; 346 | border-radius: 0.5rem; 347 | background: rgba(255, 255, 255, 0.1); 348 | color: white; 349 | cursor: pointer; 350 | transition: all 0.3s ease; 351 | } 352 | 353 | .toggle-btn.active { 354 | background: linear-gradient(45deg, #646cff, #61dafb); 355 | } 356 | 357 | .file-input, 358 | .url-input { 359 | width: 100%; 360 | padding: 1rem; 361 | border-radius: 0.5rem; 362 | border: 1px solid rgba(255, 255, 255, 0.1); 363 | background: rgba(255, 255, 255, 0.05); 364 | color: white; 365 | } 366 | 367 | .checkbox-group { 368 | display: flex; 369 | align-items: center; 370 | gap: 2rem; 371 | color: white; 372 | margin: 1rem 0; 373 | flex-wrap: wrap; 374 | } 375 | 376 | .checkbox-group input[type="checkbox"] { 377 | width: 20px; 378 | height: 20px; 379 | cursor: pointer; 380 | accent-color: #646cff; 381 | } 382 | 383 | .checkbox-group label { 384 | display: flex; 385 | align-items: center; 386 | gap: 1rem; 387 | font-size: 1.1rem; 388 | cursor: pointer; 389 | min-width: 150px; 390 | } 391 | 392 | .swap-button { 393 | padding: 1rem 2rem; 394 | border: none; 395 | border-radius: 0.5rem; 396 | background: linear-gradient(45deg, #FF3CAC, #784BA0, #2B86C5); 397 | color: white; 398 | font-size: 1.1rem; 399 | cursor: pointer; 400 | transition: all 0.3s ease; 401 | box-shadow: 0 4px 15px rgba(123, 74, 160, 0.2); 402 | position: relative; 403 | overflow: hidden; 404 | } 405 | 406 | .swap-button:hover { 407 | transform: translateY(-2px); 408 | box-shadow: 0 8px 25px rgba(123, 74, 160, 0.4); 409 | } 410 | 411 | .swap-button:active { 412 | transform: translateY(1px); 413 | } 414 | 415 | .swap-button:disabled { 416 | opacity: 0.5; 417 | cursor: not-allowed; 418 | transform: none; 419 | box-shadow: none; 420 | } 421 | 422 | .loader-overlay { 423 | position: fixed; 424 | top: 0; 425 | left: 0; 426 | right: 0; 427 | bottom: 0; 428 | width: 100%; 429 | height: 100%; 430 | background: rgba(0, 0, 0, 0.8); 431 | display: flex; 432 | flex-direction: column; 433 | justify-content: center; 434 | align-items: center; 435 | gap: 1rem; 436 | z-index: 9999; 437 | } 438 | 439 | .loader { 440 | width: 50px; 441 | height: 50px; 442 | border: 5px solid #f3f3f3; 443 | border-top: 5px solid #646cff; 444 | border-radius: 50%; 445 | animation: spin 1s linear infinite; 446 | } 447 | 448 | @keyframes spin { 449 | 0% { transform: rotate(0deg); } 450 | 100% { transform: rotate(360deg); } 451 | } 452 | 453 | /* Custom file input styling */ 454 | .file-input { 455 | width: 100%; 456 | padding: 1rem; 457 | border-radius: 0.5rem; 458 | border: 2px dashed rgba(255, 255, 255, 0.2); 459 | background: rgba(255, 255, 255, 0.05); 460 | color: white; 461 | cursor: pointer; 462 | transition: all 0.3s ease; 463 | position: relative; 464 | } 465 | 466 | .file-input:hover { 467 | border-color: #646cff; 468 | background: rgba(100, 108, 255, 0.08); 469 | } 470 | 471 | .file-input::file-selector-button { 472 | padding: 0.5rem 1rem; 473 | margin-right: 1rem; 474 | border: none; 475 | border-radius: 0.3rem; 476 | background: linear-gradient(45deg, #2a2a2a, #1a1a1a); 477 | color: white; 478 | cursor: pointer; 479 | transition: all 0.3s ease; 480 | } 481 | 482 | .file-input::file-selector-button:hover { 483 | transform: translateY(-1px); 484 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); 485 | } 486 | 487 | /* Add styles for the loader text */ 488 | .loader-overlay p { 489 | color: white; 490 | font-size: 1.2rem; 491 | margin-top: 1rem; 492 | } 493 | 494 | /* Prevent body scrolling when loader is active */ 495 | body.loading { 496 | overflow: hidden; 497 | position: fixed; 498 | width: 100%; 499 | height: 100%; 500 | } 501 | 502 | .result-popup-overlay { 503 | position: fixed; 504 | top: 0; 505 | left: 0; 506 | width: 100vw; 507 | height: 100vh; 508 | background: rgba(0, 0, 0, 0.85); 509 | display: flex; 510 | justify-content: center; 511 | align-items: center; 512 | z-index: 1000; 513 | animation: fadeIn 0.3s ease-out; 514 | } 515 | 516 | .result-popup { 517 | background: #1a1a1a; 518 | border-radius: 1rem; 519 | padding: 2rem; 520 | max-width: 90vw; 521 | max-height: 90vh; 522 | width: auto; 523 | position: relative; 524 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); 525 | animation: slideUp 0.3s ease-out; 526 | } 527 | 528 | .result-popup h2 { 529 | color: white; 530 | margin-bottom: 1.5rem; 531 | text-align: center; 532 | font-size: 1.5rem; 533 | } 534 | 535 | .result-image-container { 536 | max-height: 60vh; 537 | overflow: auto; 538 | margin-bottom: 1.5rem; 539 | border-radius: 0.5rem; 540 | background: rgba(0, 0, 0, 0.2); 541 | padding: 1rem; 542 | } 543 | 544 | .result-image-container img { 545 | max-width: 100%; 546 | height: auto; 547 | border-radius: 0.5rem; 548 | } 549 | 550 | .result-actions { 551 | display: flex; 552 | gap: 1rem; 553 | justify-content: center; 554 | } 555 | 556 | .download-button { 557 | padding: 0.8rem 1.5rem; 558 | border: none; 559 | border-radius: 0.5rem; 560 | background: linear-gradient(45deg, #646cff, #61dafb); 561 | color: white; 562 | font-size: 1rem; 563 | cursor: pointer; 564 | transition: all 0.3s ease; 565 | } 566 | 567 | .close-popup-button { 568 | padding: 0.8rem 1.5rem; 569 | border: none; 570 | border-radius: 0.5rem; 571 | background: rgba(255, 255, 255, 0.1); 572 | color: white; 573 | font-size: 1rem; 574 | cursor: pointer; 575 | transition: all 0.3s ease; 576 | } 577 | 578 | .close-button { 579 | position: absolute; 580 | top: 1rem; 581 | right: 1rem; 582 | background: none; 583 | border: none; 584 | color: white; 585 | font-size: 1.5rem; 586 | cursor: pointer; 587 | width: 2rem; 588 | height: 2rem; 589 | display: flex; 590 | align-items: center; 591 | justify-content: center; 592 | border-radius: 50%; 593 | transition: all 0.3s ease; 594 | } 595 | 596 | .close-button:hover { 597 | background: rgba(255, 255, 255, 0.1); 598 | } 599 | 600 | .download-button:hover, 601 | .close-popup-button:hover { 602 | transform: translateY(-2px); 603 | box-shadow: 0 5px 15px rgba(97, 218, 251, 0.2); 604 | } 605 | 606 | @keyframes slideUp { 607 | from { 608 | opacity: 0; 609 | transform: translateY(20px); 610 | } 611 | to { 612 | opacity: 1; 613 | transform: translateY(0); 614 | } 615 | } 616 | 617 | .main-header { 618 | display: flex; 619 | justify-content: space-between; 620 | align-items: center; 621 | margin-bottom: 2rem; 622 | padding: 1rem; 623 | position: relative; 624 | } 625 | 626 | .header-left { 627 | position: static; 628 | transform: none; 629 | display: flex; 630 | align-items: center; 631 | gap: 1.5rem; 632 | } 633 | 634 | .header-left .logo { 635 | width: auto; 636 | height: 6.5rem; 637 | filter: drop-shadow(0 0 10px rgba(97, 218, 251, 0.5)); 638 | } 639 | 640 | .header-left .title { 641 | font-size: 2.5rem; 642 | background: linear-gradient(45deg, #646cff, #61dafb); 643 | -webkit-background-clip: text; 644 | -webkit-text-fill-color: transparent; 645 | margin: 0; 646 | white-space: nowrap; 647 | line-height: 1; 648 | } 649 | 650 | .balance-button { 651 | padding: 0.8rem 1.2rem; 652 | right: 1rem; 653 | border-radius: 0.5rem; 654 | border: 2px solid transparent; 655 | background: linear-gradient(45deg, #2a2a2a, #1a1a1a); 656 | color: white; 657 | font-size: 1rem; 658 | cursor: pointer; 659 | transition: all 0.3s ease; 660 | position: relative; 661 | background-clip: padding-box; 662 | } 663 | 664 | .balance-button::before { 665 | content: ''; 666 | position: absolute; 667 | top: -2px; 668 | right: -2px; 669 | bottom: -2px; 670 | left: -2px; 671 | z-index: -1; 672 | border-radius: 0.6rem; 673 | background: linear-gradient(45deg, #646cff, #61dafb); 674 | } 675 | 676 | .balance-button:hover { 677 | transform: translateY(-2px); 678 | box-shadow: 0 5px 15px rgba(97, 218, 251, 0.2); 679 | } 680 | 681 | /* Add responsive styles for smaller screens */ 682 | @media (max-width: 480px) { 683 | .main-header { 684 | flex-direction: row; 685 | padding: 1rem; 686 | gap: 0; 687 | } 688 | 689 | .header-left { 690 | position: static; 691 | transform: none; 692 | } 693 | 694 | .balance-button { 695 | width: auto; 696 | } 697 | } 698 | 699 | /* Add these new styles */ 700 | 701 | .video-swap-content { 702 | background: rgba(0, 0, 0, 0.2); 703 | border-radius: 1rem; 704 | padding: 2rem; 705 | margin-top: 1rem; 706 | } 707 | 708 | .video-form { 709 | max-width: 800px; 710 | margin: 0 auto; 711 | } 712 | 713 | .video-input-wrapper { 714 | position: relative; 715 | display: flex; 716 | align-items: center; 717 | background: rgba(255, 255, 255, 0.05); 718 | border-radius: 0.5rem; 719 | padding: 0.5rem; 720 | border: 1px solid rgba(255, 255, 255, 0.1); 721 | } 722 | 723 | .video-icon, 724 | .image-icon { 725 | font-size: 1.5rem; 726 | margin-right: 1rem; 727 | opacity: 0.7; 728 | } 729 | 730 | .video-url-input { 731 | flex: 1; 732 | background: transparent; 733 | border: none; 734 | padding: 0.8rem; 735 | } 736 | 737 | .video-url-input:focus { 738 | outline: none; 739 | background: rgba(255, 255, 255, 0.08); 740 | } 741 | 742 | .video-options { 743 | display: flex; 744 | justify-content: center; 745 | margin: 2rem 0; 746 | } 747 | 748 | .video-enhance-toggle { 749 | display: flex; 750 | align-items: center; 751 | gap: 1rem; 752 | background: rgba(255, 255, 255, 0.05); 753 | padding: 1rem 2rem; 754 | border-radius: 2rem; 755 | cursor: pointer; 756 | transition: all 0.3s ease; 757 | } 758 | 759 | .video-enhance-toggle:hover { 760 | background: rgba(255, 255, 255, 0.1); 761 | } 762 | 763 | .toggle-label { 764 | color: white; 765 | font-size: 1.1rem; 766 | } 767 | 768 | .video-swap-button { 769 | background: linear-gradient(45deg, #FF3CAC, #784BA0); 770 | width: 100%; 771 | max-width: 300px; 772 | margin: 0 auto; 773 | display: block; 774 | } 775 | 776 | .video-status { 777 | margin-top: 2rem; 778 | padding: 1rem; 779 | background: rgba(255, 255, 255, 0.05); 780 | border-radius: 0.5rem; 781 | text-align: center; 782 | } 783 | 784 | .video-result-popup { 785 | max-width: 90vw; 786 | width: auto; 787 | } 788 | 789 | .result-video-container { 790 | width: 100%; 791 | max-height: 80vh; 792 | overflow: hidden; 793 | } 794 | 795 | .result-video { 796 | width: 100%; 797 | height: auto; 798 | max-height: 70vh; 799 | object-fit: contain; 800 | } 801 | 802 | .result-actions { 803 | display: flex; 804 | gap: 10px; 805 | justify-content: center; 806 | margin-top: 20px; 807 | } 808 | 809 | .result-actions .download-button { 810 | text-decoration: none; 811 | padding: 10px 20px; 812 | border-radius: 5px; 813 | background-color: #007bff; 814 | color: white; 815 | border: none; 816 | cursor: pointer; 817 | } 818 | 819 | .result-actions .download-button:hover { 820 | background-color: #0056b3; 821 | } 822 | 823 | /* Responsive adjustments for video popup */ 824 | @media (max-width: 768px) { 825 | .video-result-popup { 826 | width: 95vw; 827 | } 828 | 829 | .result-video-container { 830 | max-height: 50vh; 831 | } 832 | 833 | .result-video { 834 | max-height: 40vh; 835 | } 836 | } 837 | 838 | /* Add these new styles */ 839 | .auth-method-toggle { 840 | display: flex; 841 | justify-content: center; 842 | gap: 1rem; 843 | margin-bottom: 2rem; 844 | } 845 | 846 | .auth-toggle-btn { 847 | padding: 0.8rem 1.5rem; 848 | border: none; 849 | border-radius: 0.5rem; 850 | background: rgba(255, 255, 255, 0.1); 851 | color: white; 852 | cursor: pointer; 853 | transition: all 0.3s ease; 854 | } 855 | 856 | .auth-toggle-btn.active { 857 | background: linear-gradient(45deg, #646cff, #61dafb); 858 | box-shadow: 0 4px 15px rgba(97, 218, 251, 0.2); 859 | } 860 | 861 | .auth-toggle-btn:hover:not(.active) { 862 | background: rgba(255, 255, 255, 0.2); 863 | } 864 | 865 | .balance-button { 866 | position: relative; 867 | } 868 | 869 | .button-loader { 870 | display: inline-block; 871 | width: 20px; 872 | height: 20px; 873 | border: 2px solid rgba(255, 255, 255, 0.3); 874 | border-radius: 50%; 875 | border-top-color: #fff; 876 | animation: spin 1s ease-in-out infinite; 877 | margin: 0 8px; 878 | } 879 | 880 | @keyframes spin { 881 | to { transform: rotate(360deg); } 882 | } 883 | 884 | .balance-button:disabled { 885 | cursor: not-allowed; 886 | opacity: 0.7; 887 | } 888 | 889 | /* API Version Toggle Styles */ 890 | .api-version-toggle { 891 | display: flex; 892 | flex-direction: column; 893 | gap: 0.5rem; 894 | padding: 1rem; 895 | background: rgba(255, 255, 255, 0.05); 896 | border-radius: 0.5rem; 897 | margin-bottom: 1rem; 898 | } 899 | 900 | .api-version-toggle label { 901 | color: white; 902 | font-size: 1rem; 903 | font-weight: 600; 904 | margin-bottom: 0.5rem; 905 | } 906 | 907 | .version-buttons { 908 | display: flex; 909 | gap: 1rem; 910 | justify-content: center; 911 | } 912 | 913 | .version-btn { 914 | padding: 0.8rem 1.5rem; 915 | border: none; 916 | border-radius: 0.5rem; 917 | background: rgba(255, 255, 255, 0.1); 918 | color: white; 919 | cursor: pointer; 920 | transition: all 0.3s ease; 921 | font-size: 1rem; 922 | } 923 | 924 | .version-btn.active { 925 | background: linear-gradient(45deg, #646cff, #61dafb); 926 | box-shadow: 0 4px 15px rgba(97, 218, 251, 0.2); 927 | } 928 | 929 | .version-btn:hover:not(.active) { 930 | background: rgba(255, 255, 255, 0.2); 931 | } 932 | 933 | .api-hint { 934 | color: rgba(255, 255, 255, 0.6); 935 | font-size: 0.9rem; 936 | text-align: center; 937 | margin-top: 0.5rem; 938 | } 939 | 940 | /* Tab Description Styles */ 941 | .tab-description { 942 | color: rgba(255, 255, 255, 0.7); 943 | font-size: 0.85rem; 944 | text-align: center; 945 | margin: -0.5rem 0 1.5rem 0; 946 | padding: 0.75rem 1rem; 947 | background: rgba(100, 108, 255, 0.1); 948 | border-radius: 0.5rem; 949 | border-left: 3px solid rgba(100, 108, 255, 0.5); 950 | line-height: 1.6; 951 | font-style: italic; 952 | } 953 | --------------------------------------------------------------------------------