Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Audio-Video Lip Sync Studio</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --primary: #6366f1; | |
| --primary-dark: #4f46e5; | |
| --primary-light: #818cf8; | |
| --secondary: #ec4899; | |
| --secondary-dark: #db2777; | |
| --bg-dark: #0f0f1a; | |
| --bg-card: #1a1a2e; | |
| --bg-card-hover: #252542; | |
| --text-primary: #ffffff; | |
| --text-secondary: #a1a1aa; | |
| --text-muted: #71717a; | |
| --border: #27272a; | |
| --success: #10b981; | |
| --warning: #f59e0b; | |
| --error: #ef4444; | |
| --gradient-1: linear-gradient(135deg, #6366f1, #ec4899); | |
| --gradient-2: linear-gradient(135deg, #8b5cf6, #06b6d4); | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: var(--bg-dark); | |
| color: var(--text-primary); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* Animated Background */ | |
| .bg-animation { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 0; | |
| overflow: hidden; | |
| } | |
| .bg-animation::before { | |
| content: ''; | |
| position: absolute; | |
| top: -50%; | |
| left: -50%; | |
| width: 200%; | |
| height: 200%; | |
| background: | |
| radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%), | |
| radial-gradient(ellipse at 80% 80%, rgba(236, 72, 153, 0.1) 0%, transparent 50%); | |
| animation: bgPulse 15s ease-in-out infinite; | |
| } | |
| @keyframes bgPulse { | |
| 0%, 100% { transform: translate(0, 0) rotate(0deg); } | |
| 33% { transform: translate(2%, 2%) rotate(1deg); } | |
| 66% { transform: translate(-1%, 1%) rotate(-1deg); } | |
| } | |
| /* Header */ | |
| header { | |
| position: relative; | |
| z-index: 10; | |
| padding: 1.5rem 2rem; | |
| background: rgba(15, 15, 26, 0.8); | |
| backdrop-filter: blur(20px); | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .logo { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| } | |
| .logo-icon { | |
| width: 42px; | |
| height: 42px; | |
| background: var(--gradient-1); | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.25rem; | |
| box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4); | |
| } | |
| .logo h1 { | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| background: var(--gradient-1); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .brand-link { | |
| color: var(--text-secondary); | |
| text-decoration: none; | |
| font-size: 0.75rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.5rem 1rem; | |
| background: var(--bg-card); | |
| border-radius: 20px; | |
| transition: all 0.3s ease; | |
| } | |
| .brand-link:hover { | |
| background: var(--bg-card-hover); | |
| color: var(--primary-light); | |
| } | |
| /* Main Content */ | |
| main { | |
| position: relative; | |
| z-index: 1; | |
| padding: 2rem; | |
| max-width: 1600px; | |
| margin: 0 auto; | |
| } | |
| /* Upload Section */ | |
| .upload-section { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); | |
| gap: 1.5rem; | |
| margin-bottom: 2rem; | |
| } | |
| .upload-card { | |
| background: var(--bg-card); | |
| border-radius: 20px; | |
| padding: 2rem; | |
| border: 2px dashed var(--border); | |
| transition: all 0.3s ease; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .upload-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 4px; | |
| background: var(--gradient-1); | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .upload-card:hover { | |
| border-color: var(--primary); | |
| transform: translateY(-4px); | |
| box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); | |
| } | |
| .upload-card:hover::before { | |
| opacity: 1; | |
| } | |
| .upload-card.video::before { | |
| background: var(--gradient-2); | |
| } | |
| .upload-card.has-file { | |
| border-style: solid; | |
| border-color: var(--success); | |
| } | |
| .upload-icon { | |
| width: 80px; | |
| height: 80px; | |
| background: rgba(99, 102, 241, 0.1); | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin: 0 auto 1.5rem; | |
| font-size: 2rem; | |
| color: var(--primary); | |
| transition: all 0.3s ease; | |
| } | |
| .video .upload-icon { | |
| background: rgba(139, 92, 246, 0.1); | |
| color: #8b5cf6; | |
| } | |
| .upload-card:hover .upload-icon { | |
| transform: scale(1.1) rotate(5deg); | |
| } | |
| .upload-title { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| text-align: center; | |
| } | |
| .upload-subtitle { | |
| color: var(--text-secondary); | |
| font-size: 0.875rem; | |
| text-align: center; | |
| margin-bottom: 1.5rem; | |
| } | |
| .file-input { | |
| display: none; | |
| } | |
| .upload-btn { | |
| width: 100%; | |
| padding: 1rem; | |
| background: var(--gradient-1); | |
| border: none; | |
| border-radius: 12px; | |
| color: white; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .video .upload-btn { | |
| background: var(--gradient-2); | |
| } | |
| .upload-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 30px rgba(99, 102, 241, 0.4); | |
| } | |
| .file-info { | |
| display: none; | |
| margin-top: 1rem; | |
| padding: 1rem; | |
| background: rgba(16, 185, 129, 0.1); | |
| border-radius: 12px; | |
| text-align: center; | |
| } | |
| .file-info.visible { | |
| display: block; | |
| } | |
| .file-info i { | |
| color: var(--success); | |
| margin-right: 0.5rem; | |
| } | |
| .file-name { | |
| font-weight: 600; | |
| color: var(--success); | |
| word-break: break-all; | |
| } | |
| /* Sync Controls */ | |
| .sync-section { | |
| background: var(--bg-card); | |
| border-radius: 24px; | |
| padding: 2rem; | |
| margin-bottom: 2rem; | |
| border: 1px solid var(--border); | |
| } | |
| .section-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| margin-bottom: 2rem; | |
| } | |
| .section-icon { | |
| width: 48px; | |
| height: 48px; | |
| background: var(--gradient-1); | |
| border-radius: 14px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.25rem; | |
| } | |
| .section-title { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| } | |
| .sync-controls { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| .control-group { | |
| background: rgba(255, 255, 255, 0.03); | |
| padding: 1.5rem; | |
| border-radius: 16px; | |
| border: 1px solid var(--border); | |
| } | |
| .control-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| font-weight: 600; | |
| margin-bottom: 1rem; | |
| color: var(--text-secondary); | |
| } | |
| .control-label i { | |
| color: var(--primary); | |
| } | |
| .slider-container { | |
| position: relative; | |
| } | |
| .time-slider { | |
| width: 100%; | |
| height: 8px; | |
| -webkit-appearance: none; | |
| background: var(--border); | |
| border-radius: 4px; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| .time-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 24px; | |
| height: 24px; | |
| background: var(--gradient-1); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| box-shadow: 0 4px 15px rgba(99, 102, 241, 0.5); | |
| transition: transform 0.2s ease; | |
| } | |
| .time-slider::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| .time-value { | |
| text-align: center; | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| color: var(--primary); | |
| margin-top: 1rem; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .btn-group { | |
| display: flex; | |
| gap: 0.75rem; | |
| margin-top: 1rem; | |
| } | |
| .btn { | |
| flex: 1; | |
| padding: 0.875rem 1.5rem; | |
| border: none; | |
| border-radius: 12px; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .btn-primary { | |
| background: var(--primary); | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| background: var(--primary-dark); | |
| transform: translateY(-2px); | |
| } | |
| .btn-secondary { | |
| background: var(--bg-card-hover); | |
| color: var(--text-primary); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-secondary:hover { | |
| background: var(--border); | |
| } | |
| /* Preview Section */ | |
| .preview-section { | |
| display: grid; | |
| grid-template-columns: 2fr 1fr; | |
| gap: 2rem; | |
| margin-bottom: 2rem; | |
| } | |
| @media (max-width: 1024px) { | |
| .preview-section { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .video-container { | |
| background: var(--bg-card); | |
| border-radius: 24px; | |
| padding: 1.5rem; | |
| border: 1px solid var(--border); | |
| } | |
| .video-wrapper { | |
| position: relative; | |
| width: 100%; | |
| background: #000; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| aspect-ratio: 16/9; | |
| } | |
| .video-wrapper video { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| } | |
| .video-placeholder { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%); | |
| color: var(--text-muted); | |
| } | |
| .video-placeholder i { | |
| font-size: 4rem; | |
| margin-bottom: 1rem; | |
| opacity: 0.5; | |
| } | |
| /* Waveform */ | |
| .waveform-container { | |
| background: var(--bg-card); | |
| border-radius: 24px; | |
| padding: 1.5rem; | |
| border: 1px solid var(--border); | |
| } | |
| .waveform-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1rem; | |
| } | |
| .waveform-title { | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .waveform-title i { | |
| color: var(--primary); | |
| } | |
| #waveform-canvas { | |
| width: 100%; | |
| height: 150px; | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 12px; | |
| } | |
| /* Playback Controls */ | |
| .playback-controls { | |
| background: var(--bg-card); | |
| border-radius: 24px; | |
| padding: 2rem; | |
| border: 1px solid var(--border); | |
| margin-bottom: 2rem; | |
| } | |
| .main-controls { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .control-btn { | |
| width: 56px; | |
| height: 56px; | |
| border-radius: 50%; | |
| border: none; | |
| background: var(--bg-card-hover); | |
| color: var(--text-primary); | |
| font-size: 1.25rem; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .control-btn:hover { | |
| background: var(--primary); | |
| transform: scale(1.1); | |
| } | |
| .control-btn.play { | |
| width: 72px; | |
| height: 72px; | |
| background: var(--gradient-1); | |
| font-size: 1.5rem; | |
| box-shadow: 0 8px 30px rgba(99, 102, 241, 0.4); | |
| } | |
| .control-btn.play:hover { | |
| transform: scale(1.15); | |
| } | |
| .progress-container { | |
| position: relative; | |
| margin-bottom: 1rem; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 10px; | |
| background: var(--border); | |
| border-radius: 5px; | |
| cursor: pointer; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: var(--gradient-1); | |
| border-radius: 5px; | |
| width: 0%; | |
| transition: width 0.1s linear; | |
| } | |
| .time-display { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.875rem; | |
| color: var(--text-secondary); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .volume-control { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| justify-content: center; | |
| } | |
| .volume-slider { | |
| width: 120px; | |
| height: 6px; | |
| -webkit-appearance: none; | |
| background: var(--border); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .volume-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: var(--primary); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| /* Sync Status */ | |
| .sync-status { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 1rem; | |
| margin-top: 1.5rem; | |
| } | |
| .status-card { | |
| background: rgba(255, 255, 255, 0.03); | |
| padding: 1.25rem; | |
| border-radius: 12px; | |
| border: 1px solid var(--border); | |
| text-align: center; | |
| } | |
| .status-value { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| color: var(--primary); | |
| margin-bottom: 0.25rem; | |
| } | |
| .status-label { | |
| font-size: 0.75rem; | |
| color: var(--text-secondary); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .status-card.synced .status-value { | |
| color: var(--success); | |
| } | |
| .status-card.warning .status-value { | |
| color: var(--warning); | |
| } | |
| /* Export Section */ | |
| .export-section { | |
| background: var(--bg-card); | |
| border-radius: 24px; | |
| padding: 2rem; | |
| border: 1px solid var(--border); | |
| text-align: center; | |
| } | |
| .export-info { | |
| margin-bottom: 1.5rem; | |
| color: var(--text-secondary); | |
| } | |
| .export-actions { | |
| display: flex; | |
| gap: 1rem; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| } | |
| .btn-export { | |
| padding: 1rem 2rem; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| border: none; | |
| } | |
| .btn-export.sync { | |
| background: var(--gradient-1); | |
| color: white; | |
| } | |
| .btn-export.sync:hover { | |
| transform: translateY(-3px); | |
| box-shadow: 0 15px 40px rgba(99, 102, 241, 0.4); | |
| } | |
| .btn-export.reset { | |
| background: transparent; | |
| color: var(--text-secondary); | |
| border: 2px solid var(--border); | |
| } | |
| .btn-export.reset:hover { | |
| border-color: var(--error); | |
| color: var(--error); | |
| } | |
| /* Toast Notifications */ | |
| .toast-container { | |
| position: fixed; | |
| top: 2rem; | |
| right: 2rem; | |
| z-index: 1000; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| } | |
| .toast { | |
| padding: 1rem 1.5rem; | |
| background: var(--bg-card); | |
| border-radius: 12px; | |
| border-left: 4px solid var(--primary); | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); | |
| animation: slideIn 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| } | |
| .toast.success { | |
| border-color: var(--success); | |
| } | |
| .toast.error { | |
| border-color: var(--error); | |
| } | |
| .toast.warning { | |
| border-color: var(--warning); | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| header { | |
| padding: 1rem; | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| main { | |
| padding: 1rem; | |
| } | |
| .upload-section { | |
| grid-template-columns: 1fr; | |
| } | |
| .sync-section, | |
| .playback-controls { | |
| padding: 1.5rem; | |
| } | |
| .control-btn { | |
| width: 48px; | |
| height: 48px; | |
| } | |
| .control-btn.play { | |
| width: 64px; | |
| height: 64px; | |
| } | |
| } | |
| /* Loading Animation */ | |
| .loading { | |
| display: inline-block; | |
| width: 20px; | |
| height: 20px; | |
| border: 2px solid var(--border); | |
| border-top-color: var(--primary); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Visual Feedback for Sync */ | |
| .sync-indicator { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.5rem 1rem; | |
| background: rgba(16, 185, 129, 0.1); | |
| border-radius: 20px; | |
| font-size: 0.875rem; | |
| color: var(--success); | |
| } | |
| .sync-indicator.unsynced { | |
| background: rgba(239, 68, 68, 0.1); | |
| color: var(--error); | |
| } | |
| .sync-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bg-animation"></div> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-icon"> | |
| <i class="fas fa-wave-square"></i> | |
| </div> | |
| <h1>Lip Sync Studio</h1> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="brand-link"> | |
| <i class="fas fa-robot"></i> | |
| Built with anycoder | |
| </a> | |
| </header> | |
| <main> | |
| <!-- Upload Section --> | |
| <section class="upload-section"> | |
| <div class="upload-card video" id="video-upload-card"> | |
| <div class="upload-icon"> | |
| <i class="fas fa-video"></i> | |
| </div> | |
| <h3 class="upload-title">Upload Video</h3> | |
| <p class="upload-subtitle">Select an MP4 file to use as the video source</p> | |
| <input type="file" id="video-input" class="file-input" accept="video/mp4,video/webm"> | |
| <button class="upload-btn" onclick="document.getElementById('video-input').click()"> | |
| <i class="fas fa-folder-open"></i> | |
| Choose Video File | |
| </button> | |
| <div class="file-info" id="video-file-info"> | |
| <i class="fas fa-check-circle"></i> | |
| <span class="file-name" id="video-filename"></span> | |
| </div> | |
| </div> | |
| <div class="upload-card" id="audio-upload-card"> | |
| <div class="upload-icon"> | |
| <i class="fas fa-music"></i> | |
| </div> | |
| <h3 class="upload-title">Upload Audio</h3> | |
| <p class="upload-subtitle">Select an MP3 file to synchronize</p> | |
| <input type="file" id="audio-input" class="file-input" accept="audio/mp3,audio/wav,audio/mpeg"> | |
| <button class="upload-btn" onclick="document.getElementById('audio-input').click()"> | |
| <i class="fas fa-folder-open"></i> | |
| Choose Audio File | |
| </button> | |
| <div class="file-info" id="audio-file-info"> | |
| <i class="fas fa-check-circle"></i> | |
| <span class="file-name" id="audio-filename"></span> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Preview Section --> | |
| <section class="preview-section"> | |
| <div class="video-container"> | |
| <div class="waveform-header"> | |
| <span class="waveform-title"> | |
| <i class="fas fa-desktop"></i> | |
| Video Preview | |
| </span> | |
| <span class="sync-indicator unsynced" id="sync-indicator"> | |
| <span class="sync-dot"></span> | |
| <span id="sync-text">Not Synced</span> | |
| </span> | |
| </div> | |
| <div class="video-wrapper"> | |
| <video id="video-player" playsinline></video> | |
| <div class="video-placeholder" id="video-placeholder"> | |
| <i class="fas fa-film"></i> | |
| <span>Video will appear here</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="waveform-container"> | |
| <div class="waveform-header"> | |
| <span class="waveform-title"> | |
| <i class="fas fa-wave-square"></i> | |
| Audio Waveform | |
| </span> | |
| </div> | |
| <canvas id="waveform-canvas"></canvas> | |
| </div> | |
| </section> | |
| <!-- Sync Controls --> | |
| <section class="sync-section"> | |
| <div class="section-header"> | |
| <div class="section-icon"> | |
| <i class="fas fa-sliders-h"></i> | |
| </div> | |
| <h2 class="section-title">Synchronization Controls</h2> | |
| </div> | |
| <div class="sync-controls"> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <i class="fas fa-clock"></i> | |
| Audio Offset (Delay) | |
| </div> | |
| <div class="slider-container"> | |
| <input type="range" class="time-slider" id="offset-slider" min="-3000" max="3000" value="0" step="10"> | |
| </div> | |
| <div class="time-value" id="offset-value">0ms</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-secondary" onclick="adjustOffset(-100)"> | |
| <i class="fas fa-minus"></i> -100ms | |
| </button> | |
| <button class="btn btn-secondary" onclick="adjustOffset(100)"> | |
| <i class="fas fa-plus"></i> +100ms | |
| </button> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <i class="fas fa-play"></i> | |
| Playback Rate | |
| </div> | |
| <div class="slider-container"> | |
| <input type="range" class="time-slider" id="rate-slider" min="0.5" max="2" value="1" step="0.05"> | |
| </div > | |
| <div class="time-value" id="rate-value">1.0x</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-secondary" onclick="adjustRate(-0.1)"> | |
| <i class="fas fa-minus"></i> Slower | |
| </button> | |
| <button class="btn btn-secondary" onclick="adjustRate(0.1)"> | |
| <i class="fas fa-plus"></i> Faster | |
| </button> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <i class="fas fa-volume-up"></i> | |
| Audio Volume | |
| </div> | |
| <div class="slider-container"> | |
| <input type="range" class="time-slider" id="volume-slider" min="0" max="2" value="1" step="0.1"> | |
| </div> | |
| <div class="time-value" id="volume-value">100%</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-secondary" onclick="setVolume(0)"> | |
| <i class="fas fa-volume-mute"></i> Mute | |
| </button> | |
| <button class="btn btn-secondary" onclick="setVolume(1)"> | |
| <i class="fas fa-volume-up"></i> Normal | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Playback Controls --> | |
| <section class="playback-controls"> | |
| <div class="main-controls"> | |
| <button class="control-btn" id="skip-back-btn" title="Skip Back 5s"> | |
| <i class="fas fa-backward"></i> | |
| </button> | |
| <button class="control-btn" id="rewind-btn" title="Rewind 1s"> | |
| <i class="fas fa-step-backward"></i> | |
| </button> | |
| <button class="control-btn play" id="play-btn" title="Play/Pause"> | |
| <i class="fas fa-play"></i> | |
| </button> | |
| <button class="control-btn" id="forward-btn" title="Forward 1s"> | |
| <i class="fas fa-step-forward"></i> | |
| </button> | |
| <button class="control-btn" id="skip-forward-btn" title="Skip Forward 5s"> | |
| <i class="fas fa-forward"></i> | |
| </button> | |
| </div> | |
| <div class="progress-container"> | |
| <div class="progress-bar" id="progress-bar"> | |
| <div class="progress-fill" id="progress-fill"></div> | |
| </div> | |
| <div class="time-display"> | |
| <span id="current-time">00:00.000</span> | |
| <span id="duration">00:00.000</span> | |
| </div> | |
| </div> | |
| <div class="volume-control"> | |
| <i class="fas fa-volume-down" style="color: var(--text-secondary);"></i> | |
| <input type="range" class="volume-slider" id="main-volume" min="0" max="1" value="1" step="0.01"> | |
| <i class="fas fa-volume-up" style="color: var(--text-secondary);"></i> | |
| </div> | |
| <div class="sync-status"> | |
| <div class="status-card" id="offset-status"> | |
| <div class="status-value" id="status-offset">0ms</div> | |
| <div class="status-label">Audio Offset</div> | |
| </div> | |
| <div class="status-card" id="rate-status"> | |
| <div class="status-value" id="status-rate">1.0x</div> | |
| <div class="status-label">Playback Rate</div> | |
| </div> | |
| <div class="status-card" id="video-status"> | |
| <div class="status-value" id="status-video">--</div> | |
| <div class="status-label">Video Loaded</div> | |
| </div> | |
| <div class="status-card" id="audio-status"> | |
| <div class="status-value" id="status-audio">--</div> | |
| <div class="status-label">Audio Loaded</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Export Section --> | |
| <section class="export-section"> | |
| <div class="section-header" style="justify-content: center;"> | |
| <div class="section-icon"> | |
| <i class="fas fa-download"></i> | |
| </div> | |
| <h2 class="section-title">Export Settings</h2> | |
| </div> | |
| <p class="export-info">Save your synchronization settings to apply them later or share with others.</p> | |
| <div class="export-actions"> | |
| <button class="btn-export sync" onclick="exportSettings()"> | |
| <i class="fas fa-save"></i> | |
| Save Sync Settings | |
| </button> | |
| <button class="btn-export reset" onclick="resetAll()"> | |
| <i class="fas fa-undo"></i> | |
| Reset All | |
| </button> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Toast Container --> | |
| <div class="toast-container" id="toast-container"></div> | |
| <script> | |
| // Global state | |
| const state = { | |
| videoFile: null, | |
| audioFile: null, | |
| audioContext: null, | |
| audioBuffer: null, | |
| audioSource: null, | |
| isPlaying: false, | |
| audioOffset: 0, | |
| playbackRate: 1, | |
| volume: 1, | |
| startTime: 0, | |
| pausedAt: 0, | |
| videoElement: null, | |
| audioElement: null | |
| }; | |
| // DOM Elements | |
| const elements = { | |
| videoInput: document.getElementById('video-input'), | |
| audioInput: document.getElementById('audio-input'), | |
| videoPlayer: document.getElementById('video-player'), | |
| videoPlaceholder: document.getElementById('video-placeholder'), | |
| waveformCanvas: document.getElementById('waveform-canvas'), | |
| playBtn: document.getElementById('play-btn'), | |
| progressBar: document.getElementById('progress-bar'), | |
| progressFill: document.getElementById('progress-fill'), | |
| currentTime: document.getElementById('current-time'), | |
| duration: document.getElementById('duration'), | |
| offsetSlider: document.getElementById('offset-slider'), | |
| offsetValue: document.getElementById('offset-value'), | |
| rateSlider: document.getElementById('rate-slider'), | |
| rateValue: document.getElementById('rate-value'), | |
| volumeSlider: document.getElementById('volume-slider'), | |
| volumeValue: document.getElementById('volume-value'), | |
| mainVolume: document.getElementById('main-volume'), | |
| syncIndicator: document.getElementById('sync-indicator'), | |
| syncText: document.getElementById('sync-text') | |
| }; | |
| // Initialize | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initializeEventListeners(); | |
| drawEmptyWaveform(); | |
| }); | |
| function initializeEventListeners() { | |
| // File inputs | |
| elements.videoInput.addEventListener('change', handleVideoUpload); | |
| elements.audioInput.addEventListener('change', handleAudioUpload); | |
| // Playback controls | |
| elements.playBtn.addEventListener('click', togglePlay); | |
| document.getElementById('skip-back-btn').addEventListener('click', () => skip(-5)); | |
| document.getElementById('rewind-btn').addEventListener('click', () => skip(-1)); | |
| document.getElementById('forward-btn').addEventListener('click', () => skip(1)); | |
| document.getElementById('skip-forward-btn').addEventListener('click', () => skip(5)); | |
| // Progress bar | |
| elements.progressBar.addEventListener('click', seek); | |
| // Sliders | |
| elements.offsetSlider.addEventListener('input', updateOffset); | |
| elements.rateSlider.addEventListener('input', updateRate); | |
| elements.volumeSlider.addEventListener('input', updateVolume); | |
| elements.mainVolume.addEventListener('input', (e) => { | |
| const vol = parseFloat(e.target.value); | |
| setVolume(vol); | |
| }); | |
| // Video events | |
| elements.videoPlayer.addEventListener('loadedmetadata', updateDuration); | |
| elements.videoPlayer.addEventListener('timeupdate', updateProgress); | |
| elements.videoPlayer.addEventListener('ended', () => { | |
| state.isPlaying = false; | |
| updatePlayButton(); | |
| }); | |
| } | |
| // File Upload Handlers | |
| function handleVideoUpload(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| state.videoFile = file; | |
| const url = URL.createObjectURL(file); | |
| elements.videoPlayer.src = url; | |
| elements.videoPlayer.load(); | |
| elements.videoPlaceholder.style.display = 'none'; | |
| // Update UI | |
| document.getElementById('video-upload-card').classList.add('has-file'); | |
| document.getElementById('video-filename').textContent = file.name; | |
| document.getElementById('video-file-info').classList.add('visible'); | |
| document.getElementById('status-video').textContent = '✓ Loaded'; | |
| showToast('Video loaded successfully!', 'success'); | |
| checkSyncStatus(); | |
| } | |
| async function handleAudioUpload(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| state.audioFile = file; | |
| try { | |
| // Create audio element for playback | |
| if (state.audioElement) { | |
| state.audioElement.pause(); | |
| state.audioElement = null; | |
| } | |
| const audioUrl = URL.createObjectURL(file); | |
| state.audioElement = new Audio(audioUrl); | |
| // Setup Web Audio API for visualization | |
| await setupAudioContext(file); | |
| // Update UI | |
| document.getElementById('audio-upload-card').classList.add('has-file'); | |
| document.getElementById('audio-filename').textContent = file.name; | |
| document.getElementById('audio-file-info').classList.add('visible'); | |
| document.getElementById('status-audio').textContent = '✓ Loaded'; | |
| showToast('Audio loaded successfully!', 'success'); | |
| checkSyncStatus(); | |
| } catch (error) { | |
| showToast('Error loading audio: ' + error.message, 'error'); | |
| } | |
| } | |
| async function setupAudioContext(file) { | |
| if (!state.audioContext) { | |
| state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| const arrayBuffer = await file.arrayBuffer(); | |
| state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); | |
| drawWaveform(); | |
| } | |
| // Waveform Drawing | |
| function drawEmptyWaveform() { | |
| const canvas = elements.waveformCanvas; | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = canvas.offsetWidth * dpr; | |
| canvas.height = canvas.offsetHeight * dpr; | |
| ctx.scale(dpr, dpr); | |
| const width = canvas.offsetWidth; | |
| const height = canvas.offsetHeight; | |
| // Background | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; | |
| ctx.fillRect(0, 0, width, height); | |
| // Center line | |
| ctx.strokeStyle = 'rgba(99, 102, 241, 0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, height / 2); | |
| ctx.lineTo(width, height / 2); | |
| ctx.stroke(); | |
| // Text | |
| ctx.fillStyle = 'rgba(161, 161, 170, 0.5)'; | |
| ctx.font = '14px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Upload audio to see waveform', width / 2, height / 2 + 5); | |
| } | |
| function drawWaveform() { | |
| if (!state.audioBuffer) return; | |
| const canvas = elements.waveformCanvas; | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = canvas.offsetWidth * dpr; | |
| canvas.height = canvas.offsetHeight * dpr; | |
| ctx.scale(dpr, dpr); | |
| const width = canvas.offsetWidth; | |
| const height = canvas.offsetHeight; | |
| const data = state.audioBuffer.getChannelData(0); | |
| const step = Math.ceil(data.length / width); | |
| const amp = height / 2; | |
| // Clear | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; | |
| ctx.fillRect(0, 0, width, height); | |
| // Draw waveform | |
| const gradient = ctx.createLinearGradient(0, 0, width, 0); | |
| gradient.addColorStop(0, '#6366f1'); | |
| gradient.addColorStop(0.5, '#8b5cf6'); | |
| gradient.addColorStop(1, '#ec4899'); | |
| ctx.fillStyle = gradient; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, amp); | |
| for (let i = 0; i < width; i++) { | |
| let min = 1.0; | |
| let max = -1.0; | |
| for (let j = 0; j < step; j++) { | |
| const datum = data[(i * step) + j]; | |
| if (datum < min) min = datum; | |
| if (datum > max) max = datum; | |
| } | |
| ctx.lineTo(i, (1 + min) * amp); | |
| ctx.lineTo(i, (1 + max) * amp); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Center line | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, height / 2); | |
| ctx.lineTo(width, height / 2); | |
| ctx.stroke(); | |
| } | |
| // Playback Controls | |
| function togglePlay() { | |
| if (!state.videoFile && !state.audioFile) { | |
| showToast('Please upload a video or audio file first', 'warning'); | |
| return; | |
| } | |
| if (state.isPlaying) { | |
| pause(); | |
| } else { | |
| play(); | |
| } | |
| } | |
| function play() { | |
| state.isPlaying = true; | |
| if (state.videoFile) { | |
| elements.videoPlayer.play(); | |
| } | |
| if (state.audioFile && state.audioElement) { | |
| if (state.audioContext && state.audioContext.state === 'suspended') { | |
| state.audioContext.resume(); | |
| } | |
| state.audioElement.currentTime = elements.videoPlayer.currentTime + (state.audioOffset / 1000); | |
| state.audioElement.playbackRate = state.playbackRate; | |
| state.audioElement.volume = state.volume; | |
| state.audioElement.play(); | |
| } | |
| updatePlayButton(); | |
| showToast('Playback started', 'success'); | |
| } | |
| function pause() { | |
| state.isPlaying = false; | |
| if (state.videoFile) { | |
| elements.videoPlayer.pause(); | |
| } | |
| if (state.audioElement) { | |
| state.audioElement.pause(); | |
| } | |
| updatePlayButton(); | |
| } | |
| function updatePlayButton() { | |
| const icon = elements.playBtn.querySelector('i'); | |
| icon.className = state.isPlaying ? 'fas fa-pause' : 'fas fa-play'; | |
| } | |
| function skip(seconds) { | |
| if (state.videoFile) { | |
| elements.videoPlayer.currentTime = Math.max(0, elements.videoPlayer.currentTime + seconds); | |
| } | |
| } | |
| function seek(e) { | |
| const rect = elements.progressBar.getBoundingClientRect(); | |
| const pos = (e.clientX - rect.left) / rect.width; | |
| const duration = elements.videoPlayer.duration || 0; | |
| if (state.videoFile) { | |
| elements.videoPlayer.currentTime = pos * duration; | |
| } | |
| if (state.audioElement) { | |
| state.audioElement.currentTime = pos * duration; | |
| } | |
| } | |
| function updateProgress() { | |
| if (!state.videoFile) return; | |
| const current = elements.videoPlayer.currentTime; | |
| const duration = elements.videoPlayer.duration || 1; | |
| const percent = (current / duration) * 100; | |
| elements.progressFill.style.width = percent + '%'; | |
| elements.currentTime.textContent = formatTime(current); | |
| // Sync audio position | |
| if (state.audioElement && state.isPlaying) { | |
| const audioTime = current + (state.audioOffset / 1000); | |
| if (Math.abs(state.audioElement.currentTime - audioTime) > 0.1) { | |
| state.audioElement.currentTime = audioTime; | |
| } | |
| } | |
| } | |
| function updateDuration() { | |
| const duration = elements.videoPlayer.duration; | |
| elements.duration.textContent = formatTime(duration); | |
| } | |
| function formatTime(seconds) { | |
| if (isNaN(seconds)) return '00:00.000'; | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| const ms = Math.floor((seconds % 1) * 1000); | |
| return `${mins.toString().padStart(2, |