Fix YouTube DNS failure via Invidious proxy + retheme to chainstreet gold
Browse filesYouTube fix:
- HuggingFace datacenter IPs can't resolve youtube.com (DNS blocked at network level)
- New approach: detect YouTube URLs, route download through Invidious proxy instances
(inv.nadeko.net, invidious.privacydev.net, iv.melmac.space, etc.)
- Invidious /api/v1/videos/{id} gives stream info; ?local=true proxies download
through Invidious so youtube.com is never contacted
- Falls back to yt-dlp for TikTok/Instagram/non-YouTube and if all Invidious fail
- yt-dlp still uses tv_embedded/mweb clients + cookies support
Brand retheme to chainstreet gold:
- primary-500: #E8A020 (chainstreet logo gold, replaces purple #6C63FF)
- accent-500: #FFB800 (amber highlight, replaces pink #FF6B6B)
- dark-900: #0A0907 (warm near-black matching logo background)
- All glows, gradients, buttons, progress bars, scrollbar updated to gold/amber
- Hardcoded hex values updated across Hero, App, BrandKitEditor, ClipCard, api.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- frontend/src/App.jsx +3 -3
- frontend/src/components/BrandKitEditor.jsx +4 -4
- frontend/src/components/CaptionEditor.jsx +1 -1
- frontend/src/components/ClipCard.jsx +2 -2
- frontend/src/components/Hero.jsx +5 -5
- frontend/src/index.css +69 -89
- frontend/src/utils/api.js +1 -1
- frontend/tailwind.config.js +61 -45
- services/downloader.py +161 -61
|
@@ -27,13 +27,13 @@ function App() {
|
|
| 27 |
style: 'highlight_word',
|
| 28 |
fontSize: 48,
|
| 29 |
fontColor: '#FFFFFF',
|
| 30 |
-
highlightColor: '#
|
| 31 |
backgroundColor: 'rgba(0,0,0,0.6)',
|
| 32 |
position: 'bottom',
|
| 33 |
},
|
| 34 |
brandKit: {
|
| 35 |
-
primaryColor: '#
|
| 36 |
-
secondaryColor: '#
|
| 37 |
logoFile: null,
|
| 38 |
logoPreview: null,
|
| 39 |
watermarkPosition: 'top-right',
|
|
|
|
| 27 |
style: 'highlight_word',
|
| 28 |
fontSize: 48,
|
| 29 |
fontColor: '#FFFFFF',
|
| 30 |
+
highlightColor: '#E8A020',
|
| 31 |
backgroundColor: 'rgba(0,0,0,0.6)',
|
| 32 |
position: 'bottom',
|
| 33 |
},
|
| 34 |
brandKit: {
|
| 35 |
+
primaryColor: '#E8A020',
|
| 36 |
+
secondaryColor: '#FFB800',
|
| 37 |
logoFile: null,
|
| 38 |
logoPreview: null,
|
| 39 |
watermarkPosition: 'top-right',
|
|
@@ -7,8 +7,8 @@ import clsx from 'clsx'
|
|
| 7 |
* Props: { isOpen, onClose, clipId, jobId }
|
| 8 |
*/
|
| 9 |
function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
|
| 10 |
-
const [primaryColor, setPrimaryColor] = useState('#
|
| 11 |
-
const [secondaryColor, setSecondaryColor] = useState('#
|
| 12 |
const [watermarkPos, setWatermarkPos] = useState('bottom-right')
|
| 13 |
const [watermarkOpacity, setWatermarkOpacity] = useState(0.8)
|
| 14 |
const [font, setFont] = useState('Inter')
|
|
@@ -107,7 +107,7 @@ function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
|
|
| 107 |
value={primaryColor}
|
| 108 |
onChange={(e) => setPrimaryColor(e.target.value)}
|
| 109 |
className="input-field flex-1 text-sm font-mono"
|
| 110 |
-
placeholder="#
|
| 111 |
/>
|
| 112 |
</div>
|
| 113 |
</div>
|
|
@@ -127,7 +127,7 @@ function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
|
|
| 127 |
value={secondaryColor}
|
| 128 |
onChange={(e) => setSecondaryColor(e.target.value)}
|
| 129 |
className="input-field flex-1 text-sm font-mono"
|
| 130 |
-
placeholder="#
|
| 131 |
/>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
|
|
| 7 |
* Props: { isOpen, onClose, clipId, jobId }
|
| 8 |
*/
|
| 9 |
function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
|
| 10 |
+
const [primaryColor, setPrimaryColor] = useState('#E8A020')
|
| 11 |
+
const [secondaryColor, setSecondaryColor] = useState('#FFB800')
|
| 12 |
const [watermarkPos, setWatermarkPos] = useState('bottom-right')
|
| 13 |
const [watermarkOpacity, setWatermarkOpacity] = useState(0.8)
|
| 14 |
const [font, setFont] = useState('Inter')
|
|
|
|
| 107 |
value={primaryColor}
|
| 108 |
onChange={(e) => setPrimaryColor(e.target.value)}
|
| 109 |
className="input-field flex-1 text-sm font-mono"
|
| 110 |
+
placeholder="#E8A020"
|
| 111 |
/>
|
| 112 |
</div>
|
| 113 |
</div>
|
|
|
|
| 127 |
value={secondaryColor}
|
| 128 |
onChange={(e) => setSecondaryColor(e.target.value)}
|
| 129 |
className="input-field flex-1 text-sm font-mono"
|
| 130 |
+
placeholder="#FFB800"
|
| 131 |
/>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
@@ -10,7 +10,7 @@ function CaptionEditor({ isOpen, onClose, clipId, jobId }) {
|
|
| 10 |
const [style, setStyle] = useState('highlight')
|
| 11 |
const [fontSize, setFontSize] = useState(48)
|
| 12 |
const [textColor, setTextColor] = useState('#FFFFFF')
|
| 13 |
-
const [highlightColor, setHighlightColor] = useState('#
|
| 14 |
const [bgColor, setBgColor] = useState('#000000')
|
| 15 |
const [bgOpacity, setBgOpacity] = useState(0.5)
|
| 16 |
const [position, setPosition] = useState('bottom')
|
|
|
|
| 10 |
const [style, setStyle] = useState('highlight')
|
| 11 |
const [fontSize, setFontSize] = useState(48)
|
| 12 |
const [textColor, setTextColor] = useState('#FFFFFF')
|
| 13 |
+
const [highlightColor, setHighlightColor] = useState('#E8A020')
|
| 14 |
const [bgColor, setBgColor] = useState('#000000')
|
| 15 |
const [bgOpacity, setBgOpacity] = useState(0.5)
|
| 16 |
const [position, setPosition] = useState('bottom')
|
|
@@ -122,7 +122,7 @@ function ClipCard({ clip, jobId, index }) {
|
|
| 122 |
</div>
|
| 123 |
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
| 124 |
<div
|
| 125 |
-
className="h-full bg-gradient-to-r from-
|
| 126 |
style={{ width: `${clip.score_breakdown?.retention || 68}%` }}
|
| 127 |
/>
|
| 128 |
</div>
|
|
@@ -148,7 +148,7 @@ function ClipCard({ clip, jobId, index }) {
|
|
| 148 |
</div>
|
| 149 |
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
| 150 |
<div
|
| 151 |
-
className="h-full bg-gradient-to-r from-
|
| 152 |
style={{ width: `${clip.score_breakdown?.topic || 71}%` }}
|
| 153 |
/>
|
| 154 |
</div>
|
|
|
|
| 122 |
</div>
|
| 123 |
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
| 124 |
<div
|
| 125 |
+
className="h-full bg-gradient-to-r from-primary-500 to-accent-500"
|
| 126 |
style={{ width: `${clip.score_breakdown?.retention || 68}%` }}
|
| 127 |
/>
|
| 128 |
</div>
|
|
|
|
| 148 |
</div>
|
| 149 |
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
| 150 |
<div
|
| 151 |
+
className="h-full bg-gradient-to-r from-primary-600 to-accent-500"
|
| 152 |
style={{ width: `${clip.score_breakdown?.topic || 71}%` }}
|
| 153 |
/>
|
| 154 |
</div>
|
|
@@ -33,7 +33,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
|
|
| 33 |
reelType: 'Financial News Reel',
|
| 34 |
numPoints: 7,
|
| 35 |
ttsVoice: 'Guy - News Anchor (Male)',
|
| 36 |
-
accentColor: '#
|
| 37 |
groqApiKey: '',
|
| 38 |
})
|
| 39 |
const [showCookiesHelp, setShowCookiesHelp] = useState(false)
|
|
@@ -415,7 +415,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
|
|
| 415 |
<div className="flex items-center gap-3">
|
| 416 |
<input
|
| 417 |
type="color"
|
| 418 |
-
value={s.brandKit?.primaryColor || '#
|
| 419 |
onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, primaryColor: e.target.value } })}
|
| 420 |
className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
|
| 421 |
/>
|
|
@@ -429,7 +429,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
|
|
| 429 |
<div className="flex items-center gap-3">
|
| 430 |
<input
|
| 431 |
type="color"
|
| 432 |
-
value={s.brandKit?.secondaryColor || '#
|
| 433 |
onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, secondaryColor: e.target.value } })}
|
| 434 |
className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
|
| 435 |
/>
|
|
@@ -564,10 +564,10 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
|
|
| 564 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 565 |
{[
|
| 566 |
{ icon: <Zap className="w-6 h-6 text-primary-500" />, bg: 'bg-primary-500/10', title: 'Auto-Clip AI', desc: 'Intelligent scene detection and cutting' },
|
| 567 |
-
{ icon: <BarChart3 className="w-6 h-6 text-
|
| 568 |
{ icon: <Palette className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Brand Kit', desc: 'Logo watermark, custom colors & fonts' },
|
| 569 |
{ icon: <Languages className="w-6 h-6 text-cyan-500" />, bg: 'bg-cyan-500/10', title: 'Multi-Language', desc: '50+ languages, auto-translate' },
|
| 570 |
-
{ icon: <Wand2 className="w-6 h-6 text-
|
| 571 |
{ icon: <Newspaper className="w-6 h-6 text-amber-500" />, bg: 'bg-amber-500/10', title: 'Article → Reels', desc: 'Turn any article into a viral reel' },
|
| 572 |
].map(({ icon, bg, title, desc }) => (
|
| 573 |
<div key={title} className="glass p-6 rounded-2xl group hover:shadow-glow transition-all">
|
|
|
|
| 33 |
reelType: 'Financial News Reel',
|
| 34 |
numPoints: 7,
|
| 35 |
ttsVoice: 'Guy - News Anchor (Male)',
|
| 36 |
+
accentColor: '#E8A020',
|
| 37 |
groqApiKey: '',
|
| 38 |
})
|
| 39 |
const [showCookiesHelp, setShowCookiesHelp] = useState(false)
|
|
|
|
| 415 |
<div className="flex items-center gap-3">
|
| 416 |
<input
|
| 417 |
type="color"
|
| 418 |
+
value={s.brandKit?.primaryColor || '#E8A020'}
|
| 419 |
onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, primaryColor: e.target.value } })}
|
| 420 |
className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
|
| 421 |
/>
|
|
|
|
| 429 |
<div className="flex items-center gap-3">
|
| 430 |
<input
|
| 431 |
type="color"
|
| 432 |
+
value={s.brandKit?.secondaryColor || '#FFB800'}
|
| 433 |
onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, secondaryColor: e.target.value } })}
|
| 434 |
className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
|
| 435 |
/>
|
|
|
|
| 564 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 565 |
{[
|
| 566 |
{ icon: <Zap className="w-6 h-6 text-primary-500" />, bg: 'bg-primary-500/10', title: 'Auto-Clip AI', desc: 'Intelligent scene detection and cutting' },
|
| 567 |
+
{ icon: <BarChart3 className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Virality Score', desc: 'Real-time engagement predictions' },
|
| 568 |
{ icon: <Palette className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Brand Kit', desc: 'Logo watermark, custom colors & fonts' },
|
| 569 |
{ icon: <Languages className="w-6 h-6 text-cyan-500" />, bg: 'bg-cyan-500/10', title: 'Multi-Language', desc: '50+ languages, auto-translate' },
|
| 570 |
+
{ icon: <Wand2 className="w-6 h-6 text-primary-400" />, bg: 'bg-primary-500/10', title: 'Hook Rewriter', desc: 'AI-powered opening line optimization' },
|
| 571 |
{ icon: <Newspaper className="w-6 h-6 text-amber-500" />, bg: 'bg-amber-500/10', title: 'Article → Reels', desc: 'Turn any article into a viral reel' },
|
| 572 |
].map(({ icon, bg, title, desc }) => (
|
| 573 |
<div key={title} className="glass p-6 rounded-2xl group hover:shadow-glow transition-all">
|
|
@@ -16,7 +16,7 @@ body {
|
|
| 16 |
@apply bg-dark-900 text-white antialiased;
|
| 17 |
}
|
| 18 |
|
| 19 |
-
/* Scrollbar
|
| 20 |
::-webkit-scrollbar {
|
| 21 |
width: 8px;
|
| 22 |
height: 8px;
|
|
@@ -34,7 +34,7 @@ body {
|
|
| 34 |
@apply bg-primary-600;
|
| 35 |
}
|
| 36 |
|
| 37 |
-
/* Glass morphism */
|
| 38 |
.glass {
|
| 39 |
@apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl;
|
| 40 |
}
|
|
@@ -44,19 +44,22 @@ body {
|
|
| 44 |
}
|
| 45 |
|
| 46 |
.glass-lg {
|
| 47 |
-
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
-
/* Gradient text */
|
| 51 |
.gradient-text {
|
| 52 |
-
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
.gradient-text-sm {
|
| 56 |
-
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
/* Glow
|
| 60 |
.glow-border {
|
| 61 |
@apply border border-primary-500/50 shadow-glow;
|
| 62 |
}
|
|
@@ -65,20 +68,22 @@ body {
|
|
| 65 |
@apply animate-glow border border-primary-500 shadow-glow;
|
| 66 |
}
|
| 67 |
|
| 68 |
-
.glow-border-
|
| 69 |
-
@apply border border-accent-500/50 shadow-glow-
|
| 70 |
}
|
| 71 |
|
| 72 |
/* Upload zone */
|
| 73 |
.upload-zone {
|
| 74 |
-
@apply glass p-8 rounded-3xl border-2 border-dashed border-primary-500/30
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
.upload-zone.dragover {
|
| 78 |
@apply border-primary-500 bg-primary-500/10 shadow-glow-lg;
|
| 79 |
}
|
| 80 |
|
| 81 |
-
/* Cards
|
| 82 |
.card-hover {
|
| 83 |
@apply transition-all duration-300 hover:shadow-glow hover:shadow-lg hover:scale-105;
|
| 84 |
}
|
|
@@ -87,133 +92,108 @@ body {
|
|
| 87 |
@apply transition-all duration-200 hover:shadow-lg hover:scale-105;
|
| 88 |
}
|
| 89 |
|
| 90 |
-
/* Buttons */
|
| 91 |
.btn-primary {
|
| 92 |
-
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
.btn-primary-lg {
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
.btn-secondary {
|
| 100 |
-
@apply bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-lg font-semibold
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
.btn-secondary-lg {
|
| 104 |
-
@apply bg-white/10 hover:bg-white/20 text-white px-8 py-3 rounded-xl font-semibold
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
.btn-ghost {
|
| 108 |
@apply text-white/70 hover:text-white transition-colors duration-200;
|
| 109 |
}
|
| 110 |
|
| 111 |
-
/*
|
| 112 |
.input-field {
|
| 113 |
-
@apply bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-white
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
.input-field-lg {
|
| 117 |
-
@apply bg-white/5 border border-white/10 rounded-xl px-6 py-3 text-white
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
-
/*
|
| 121 |
.badge {
|
| 122 |
-
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
-
.badge-
|
| 126 |
-
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
.badge-success {
|
| 130 |
-
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
-
/* Progress bar
|
| 134 |
.progress-gradient {
|
| 135 |
-
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
/* Overlay */
|
| 139 |
.overlay {
|
| 140 |
-
@apply fixed inset-0 bg-black/
|
| 141 |
}
|
| 142 |
|
| 143 |
-
/*
|
| 144 |
-
.transition-smooth {
|
| 145 |
-
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
/* Text truncation */
|
| 149 |
-
.truncate-line {
|
| 150 |
-
@apply line-clamp-1;
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
.truncate-2 {
|
| 154 |
-
@apply line-clamp-2;
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
.truncate-3 {
|
| 158 |
-
@apply line-clamp-3;
|
| 159 |
-
}
|
| 160 |
|
| 161 |
-
/* Focus visible for accessibility */
|
| 162 |
.focus-visible {
|
| 163 |
-
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/50
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
@keyframes shimmer {
|
| 168 |
-
0%
|
| 169 |
-
|
| 170 |
-
}
|
| 171 |
-
100% {
|
| 172 |
-
background-position: 1000px 0;
|
| 173 |
-
}
|
| 174 |
}
|
| 175 |
|
| 176 |
.shimmer {
|
| 177 |
animation: shimmer 2s infinite;
|
| 178 |
background: linear-gradient(
|
| 179 |
to right,
|
| 180 |
-
rgba(255,
|
| 181 |
-
rgba(
|
| 182 |
-
rgba(255,
|
| 183 |
);
|
| 184 |
background-size: 1000px 100%;
|
| 185 |
}
|
| 186 |
|
| 187 |
-
/* Aspect ratio
|
| 188 |
-
.aspect-9-16
|
| 189 |
-
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
.aspect-4-5 {
|
| 193 |
-
@apply aspect-[4/5];
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
.aspect-16-9 {
|
| 197 |
-
@apply aspect-[16/9];
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
/* Utility classes */
|
| 201 |
-
.center {
|
| 202 |
-
@apply flex items-center justify-center;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
.flex-center {
|
| 206 |
-
@apply flex items-center justify-center;
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
.flex-between {
|
| 210 |
-
@apply flex items-center justify-between;
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
.flex-col-center {
|
| 214 |
-
@apply flex flex-col items-center justify-center;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
.text-balance {
|
| 218 |
-
text-wrap: balance;
|
| 219 |
-
}
|
|
|
|
| 16 |
@apply bg-dark-900 text-white antialiased;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
/* Scrollbar — gold on dark */
|
| 20 |
::-webkit-scrollbar {
|
| 21 |
width: 8px;
|
| 22 |
height: 8px;
|
|
|
|
| 34 |
@apply bg-primary-600;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
/* Glass morphism — warm-tinted glass */
|
| 38 |
.glass {
|
| 39 |
@apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl;
|
| 40 |
}
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
.glass-lg {
|
| 47 |
+
background: rgba(20, 16, 8, 0.55);
|
| 48 |
+
@apply backdrop-blur-2xl border border-white/10 rounded-3xl;
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/* Gradient text — gold to amber (matches chainstreet logo palette) */
|
| 52 |
.gradient-text {
|
| 53 |
+
background: linear-gradient(135deg, #E8A020 0%, #FFB800 60%, #F5C842 100%);
|
| 54 |
+
@apply bg-clip-text text-transparent;
|
| 55 |
}
|
| 56 |
|
| 57 |
.gradient-text-sm {
|
| 58 |
+
background: linear-gradient(135deg, #E8A020 0%, #FFB800 100%);
|
| 59 |
+
@apply bg-clip-text text-transparent;
|
| 60 |
}
|
| 61 |
|
| 62 |
+
/* Glow borders */
|
| 63 |
.glow-border {
|
| 64 |
@apply border border-primary-500/50 shadow-glow;
|
| 65 |
}
|
|
|
|
| 68 |
@apply animate-glow border border-primary-500 shadow-glow;
|
| 69 |
}
|
| 70 |
|
| 71 |
+
.glow-border-amber {
|
| 72 |
+
@apply border border-accent-500/50 shadow-glow-amber;
|
| 73 |
}
|
| 74 |
|
| 75 |
/* Upload zone */
|
| 76 |
.upload-zone {
|
| 77 |
+
@apply glass p-8 rounded-3xl border-2 border-dashed border-primary-500/30
|
| 78 |
+
hover:border-primary-500 transition-all duration-300 cursor-pointer
|
| 79 |
+
hover:bg-primary-500/5 hover:shadow-glow;
|
| 80 |
}
|
| 81 |
|
| 82 |
.upload-zone.dragover {
|
| 83 |
@apply border-primary-500 bg-primary-500/10 shadow-glow-lg;
|
| 84 |
}
|
| 85 |
|
| 86 |
+
/* Cards */
|
| 87 |
.card-hover {
|
| 88 |
@apply transition-all duration-300 hover:shadow-glow hover:shadow-lg hover:scale-105;
|
| 89 |
}
|
|
|
|
| 92 |
@apply transition-all duration-200 hover:shadow-lg hover:scale-105;
|
| 93 |
}
|
| 94 |
|
| 95 |
+
/* Buttons — gold gradient */
|
| 96 |
.btn-primary {
|
| 97 |
+
background: linear-gradient(135deg, #E8A020, #D4901A);
|
| 98 |
+
@apply text-black px-6 py-2 rounded-lg font-semibold transition-all duration-200
|
| 99 |
+
hover:shadow-glow hover:shadow-lg active:scale-95;
|
| 100 |
}
|
| 101 |
|
| 102 |
.btn-primary-lg {
|
| 103 |
+
background: linear-gradient(135deg, #E8A020, #D4901A);
|
| 104 |
+
@apply text-black px-8 py-3 rounded-xl font-bold transition-all duration-200
|
| 105 |
+
hover:shadow-glow-lg hover:shadow-lg active:scale-95;
|
| 106 |
}
|
| 107 |
|
| 108 |
.btn-secondary {
|
| 109 |
+
@apply bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-lg font-semibold
|
| 110 |
+
transition-all duration-200 border border-white/20;
|
| 111 |
}
|
| 112 |
|
| 113 |
.btn-secondary-lg {
|
| 114 |
+
@apply bg-white/10 hover:bg-white/20 text-white px-8 py-3 rounded-xl font-semibold
|
| 115 |
+
transition-all duration-200 border border-white/20;
|
| 116 |
}
|
| 117 |
|
| 118 |
.btn-ghost {
|
| 119 |
@apply text-white/70 hover:text-white transition-colors duration-200;
|
| 120 |
}
|
| 121 |
|
| 122 |
+
/* Inputs */
|
| 123 |
.input-field {
|
| 124 |
+
@apply bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-white
|
| 125 |
+
placeholder-white/40 focus:outline-none focus:border-primary-500/60
|
| 126 |
+
focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
|
| 127 |
}
|
| 128 |
|
| 129 |
.input-field-lg {
|
| 130 |
+
@apply bg-white/5 border border-white/10 rounded-xl px-6 py-3 text-white
|
| 131 |
+
placeholder-white/40 focus:outline-none focus:border-primary-500/60
|
| 132 |
+
focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
|
| 133 |
}
|
| 134 |
|
| 135 |
+
/* Badges */
|
| 136 |
.badge {
|
| 137 |
+
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
| 138 |
+
bg-primary-500/20 text-primary-400 border border-primary-500/30;
|
| 139 |
}
|
| 140 |
|
| 141 |
+
.badge-amber {
|
| 142 |
+
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
| 143 |
+
bg-accent-500/20 text-accent-400 border border-accent-500/30;
|
| 144 |
}
|
| 145 |
|
| 146 |
.badge-success {
|
| 147 |
+
@apply inline-block px-3 py-1 rounded-full text-sm font-medium
|
| 148 |
+
bg-green-500/20 text-green-400 border border-green-500/30;
|
| 149 |
}
|
| 150 |
|
| 151 |
+
/* Progress bar — gold gradient */
|
| 152 |
.progress-gradient {
|
| 153 |
+
background: linear-gradient(90deg, #E8A020, #FFB800, #F5C842);
|
| 154 |
+
@apply h-1 rounded-full transition-all duration-300;
|
| 155 |
}
|
| 156 |
|
| 157 |
/* Overlay */
|
| 158 |
.overlay {
|
| 159 |
+
@apply fixed inset-0 bg-black/50 backdrop-blur-sm;
|
| 160 |
}
|
| 161 |
|
| 162 |
+
/* Utility */
|
| 163 |
+
.transition-smooth { @apply transition-all duration-300 ease-out; }
|
| 164 |
+
.truncate-line { @apply line-clamp-1; }
|
| 165 |
+
.truncate-2 { @apply line-clamp-2; }
|
| 166 |
+
.truncate-3 { @apply line-clamp-3; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
|
|
|
| 168 |
.focus-visible {
|
| 169 |
+
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/50
|
| 170 |
+
focus:ring-offset-2 focus:ring-offset-dark-900;
|
| 171 |
}
|
| 172 |
|
| 173 |
+
.center { @apply flex items-center justify-center; }
|
| 174 |
+
.flex-center { @apply flex items-center justify-center; }
|
| 175 |
+
.flex-between { @apply flex items-center justify-between; }
|
| 176 |
+
.flex-col-center { @apply flex flex-col items-center justify-center; }
|
| 177 |
+
.text-balance { text-wrap: balance; }
|
| 178 |
+
|
| 179 |
+
/* Shimmer loading effect */
|
| 180 |
@keyframes shimmer {
|
| 181 |
+
0% { background-position: -1000px 0; }
|
| 182 |
+
100% { background-position: 1000px 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
.shimmer {
|
| 186 |
animation: shimmer 2s infinite;
|
| 187 |
background: linear-gradient(
|
| 188 |
to right,
|
| 189 |
+
rgba(255,255,255,0),
|
| 190 |
+
rgba(232,160,32,0.08),
|
| 191 |
+
rgba(255,255,255,0)
|
| 192 |
);
|
| 193 |
background-size: 1000px 100%;
|
| 194 |
}
|
| 195 |
|
| 196 |
+
/* Aspect ratio helpers */
|
| 197 |
+
.aspect-9-16 { @apply aspect-[9/16]; }
|
| 198 |
+
.aspect-4-5 { @apply aspect-[4/5]; }
|
| 199 |
+
.aspect-16-9 { @apply aspect-[16/9]; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -139,7 +139,7 @@ export async function generateArticleReels(articleUrl, options = {}) {
|
|
| 139 |
formData.append('reel_type', options.reelType || 'Financial News Reel')
|
| 140 |
formData.append('num_points', options.numPoints || 7)
|
| 141 |
formData.append('tts_voice', options.ttsVoice || 'Guy - News Anchor (Male)')
|
| 142 |
-
formData.append('accent_hex', options.accentColor || '#
|
| 143 |
formData.append('groq_api_key', options.groqApiKey || '')
|
| 144 |
|
| 145 |
const response = await fetch(`${API_BASE}/article-reels`, {
|
|
|
|
| 139 |
formData.append('reel_type', options.reelType || 'Financial News Reel')
|
| 140 |
formData.append('num_points', options.numPoints || 7)
|
| 141 |
formData.append('tts_voice', options.ttsVoice || 'Guy - News Anchor (Male)')
|
| 142 |
+
formData.append('accent_hex', options.accentColor || '#E8A020')
|
| 143 |
formData.append('groq_api_key', options.groqApiKey || '')
|
| 144 |
|
| 145 |
const response = await fetch(`${API_BASE}/article-reels`, {
|
|
@@ -7,87 +7,103 @@ export default {
|
|
| 7 |
theme: {
|
| 8 |
extend: {
|
| 9 |
colors: {
|
|
|
|
| 10 |
primary: {
|
| 11 |
-
50:
|
| 12 |
-
100: '#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
},
|
|
|
|
| 19 |
accent: {
|
| 20 |
-
50:
|
| 21 |
-
100: '#
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
| 27 |
dark: {
|
| 28 |
-
50:
|
| 29 |
-
100: '#
|
| 30 |
-
900: '#
|
| 31 |
-
850: '#
|
| 32 |
-
800: '#
|
| 33 |
-
700: '#
|
| 34 |
},
|
| 35 |
},
|
| 36 |
fontFamily: {
|
| 37 |
inter: ['Inter', 'sans-serif'],
|
| 38 |
},
|
| 39 |
backdropBlur: {
|
| 40 |
-
xs:
|
| 41 |
-
sm:
|
| 42 |
-
md:
|
| 43 |
-
lg:
|
| 44 |
-
xl:
|
| 45 |
-
'2xl':
|
| 46 |
},
|
| 47 |
boxShadow: {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
'glow-
|
| 51 |
-
|
|
|
|
| 52 |
},
|
| 53 |
animation: {
|
| 54 |
-
float:
|
| 55 |
-
'float-delayed':
|
| 56 |
-
glow:
|
| 57 |
-
'pulse-glow':
|
| 58 |
-
'slide-up':
|
| 59 |
-
'slide-down':
|
| 60 |
-
'fade-in':
|
| 61 |
-
'scale-in':
|
| 62 |
},
|
| 63 |
keyframes: {
|
| 64 |
float: {
|
| 65 |
'0%, 100%': { transform: 'translateY(0px)' },
|
| 66 |
-
'50%':
|
| 67 |
},
|
| 68 |
glow: {
|
| 69 |
-
'0%, 100%': { boxShadow: '0 0 20px rgba(
|
| 70 |
-
'50%':
|
| 71 |
},
|
| 72 |
'pulse-glow': {
|
| 73 |
'0%, 100%': { opacity: '1' },
|
| 74 |
-
'50%':
|
| 75 |
},
|
| 76 |
'slide-up': {
|
| 77 |
from: { opacity: '0', transform: 'translateY(10px)' },
|
| 78 |
-
to:
|
| 79 |
},
|
| 80 |
'slide-down': {
|
| 81 |
from: { opacity: '0', transform: 'translateY(-10px)' },
|
| 82 |
-
to:
|
| 83 |
},
|
| 84 |
'fade-in': {
|
| 85 |
from: { opacity: '0' },
|
| 86 |
-
to:
|
| 87 |
},
|
| 88 |
'scale-in': {
|
| 89 |
from: { opacity: '0', transform: 'scale(0.95)' },
|
| 90 |
-
to:
|
| 91 |
},
|
| 92 |
},
|
| 93 |
transitionProperty: {
|
|
|
|
| 7 |
theme: {
|
| 8 |
extend: {
|
| 9 |
colors: {
|
| 10 |
+
// Chainstreet gold — from the {} logo mark
|
| 11 |
primary: {
|
| 12 |
+
50: '#fef9ec',
|
| 13 |
+
100: '#fdf0cc',
|
| 14 |
+
200: '#fbe099',
|
| 15 |
+
300: '#f8c866',
|
| 16 |
+
400: '#f5b033',
|
| 17 |
+
500: '#E8A020', // Core chainstreet gold
|
| 18 |
+
600: '#d4901a',
|
| 19 |
+
700: '#b87a10',
|
| 20 |
+
800: '#96620a',
|
| 21 |
+
900: '#7a4e04',
|
| 22 |
},
|
| 23 |
+
// Amber highlight — lighter, used for gradients and accents
|
| 24 |
accent: {
|
| 25 |
+
50: '#fffbf0',
|
| 26 |
+
100: '#fff3d6',
|
| 27 |
+
200: '#ffe7ad',
|
| 28 |
+
300: '#ffd980',
|
| 29 |
+
400: '#ffca52',
|
| 30 |
+
500: '#FFB800', // Bright amber complement
|
| 31 |
+
600: '#e6a600',
|
| 32 |
+
700: '#cc9300',
|
| 33 |
+
800: '#b38000',
|
| 34 |
+
900: '#996d00',
|
| 35 |
+
},
|
| 36 |
+
// Chainstreet gray — from the { side of the logo
|
| 37 |
+
slate: {
|
| 38 |
+
500: '#7A7A7A',
|
| 39 |
+
600: '#6B6B6B',
|
| 40 |
+
700: '#5C5C5C',
|
| 41 |
},
|
| 42 |
dark: {
|
| 43 |
+
50: '#f0f0ec',
|
| 44 |
+
100: '#e8e8e0',
|
| 45 |
+
900: '#0A0907', // Near-black with warm tint (matches logo bg)
|
| 46 |
+
850: '#141210',
|
| 47 |
+
800: '#1e1b16',
|
| 48 |
+
700: '#2a2620',
|
| 49 |
},
|
| 50 |
},
|
| 51 |
fontFamily: {
|
| 52 |
inter: ['Inter', 'sans-serif'],
|
| 53 |
},
|
| 54 |
backdropBlur: {
|
| 55 |
+
xs: '2px',
|
| 56 |
+
sm: '4px',
|
| 57 |
+
md: '6px',
|
| 58 |
+
lg: '8px',
|
| 59 |
+
xl: '12px',
|
| 60 |
+
'2xl':'16px',
|
| 61 |
},
|
| 62 |
boxShadow: {
|
| 63 |
+
// Gold glows
|
| 64 |
+
glow: '0 0 20px rgba(232, 160, 32, 0.35)',
|
| 65 |
+
'glow-lg': '0 0 40px rgba(232, 160, 32, 0.45)',
|
| 66 |
+
'glow-amber':'0 0 20px rgba(255, 184, 0, 0.35)',
|
| 67 |
+
glass: '0 8px 32px rgba(20, 14, 4, 0.6)',
|
| 68 |
},
|
| 69 |
animation: {
|
| 70 |
+
float: 'float 3s ease-in-out infinite',
|
| 71 |
+
'float-delayed': 'float 3s ease-in-out infinite 0.5s',
|
| 72 |
+
glow: 'glow 2s ease-in-out infinite',
|
| 73 |
+
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
| 74 |
+
'slide-up': 'slide-up 0.5s ease-out',
|
| 75 |
+
'slide-down': 'slide-down 0.5s ease-out',
|
| 76 |
+
'fade-in': 'fade-in 0.5s ease-out',
|
| 77 |
+
'scale-in': 'scale-in 0.3s ease-out',
|
| 78 |
},
|
| 79 |
keyframes: {
|
| 80 |
float: {
|
| 81 |
'0%, 100%': { transform: 'translateY(0px)' },
|
| 82 |
+
'50%': { transform: 'translateY(-10px)' },
|
| 83 |
},
|
| 84 |
glow: {
|
| 85 |
+
'0%, 100%': { boxShadow: '0 0 20px rgba(232, 160, 32, 0.35)' },
|
| 86 |
+
'50%': { boxShadow: '0 0 40px rgba(232, 160, 32, 0.55)' },
|
| 87 |
},
|
| 88 |
'pulse-glow': {
|
| 89 |
'0%, 100%': { opacity: '1' },
|
| 90 |
+
'50%': { opacity: '0.7' },
|
| 91 |
},
|
| 92 |
'slide-up': {
|
| 93 |
from: { opacity: '0', transform: 'translateY(10px)' },
|
| 94 |
+
to: { opacity: '1', transform: 'translateY(0)' },
|
| 95 |
},
|
| 96 |
'slide-down': {
|
| 97 |
from: { opacity: '0', transform: 'translateY(-10px)' },
|
| 98 |
+
to: { opacity: '1', transform: 'translateY(0)' },
|
| 99 |
},
|
| 100 |
'fade-in': {
|
| 101 |
from: { opacity: '0' },
|
| 102 |
+
to: { opacity: '1' },
|
| 103 |
},
|
| 104 |
'scale-in': {
|
| 105 |
from: { opacity: '0', transform: 'scale(0.95)' },
|
| 106 |
+
to: { opacity: '1', transform: 'scale(1)' },
|
| 107 |
},
|
| 108 |
},
|
| 109 |
transitionProperty: {
|
|
@@ -1,51 +1,184 @@
|
|
| 1 |
"""
|
| 2 |
-
Video downloader using yt-dlp for YouTube, TikTok, Instagram, and direct uploads
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import logging
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import Dict, Optional
|
| 9 |
|
| 10 |
import ffmpeg
|
|
|
|
| 11 |
import yt_dlp
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
class VideoDownloader:
|
| 17 |
-
"""Download videos from various sources using yt-dlp"""
|
| 18 |
|
| 19 |
def __init__(self):
|
| 20 |
-
"""Initialize the video downloader"""
|
| 21 |
self.logger = logger
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
async def download(
|
| 24 |
self, url: str, output_dir: Path, cookies_path: Optional[str] = None
|
| 25 |
) -> Dict[str, any]:
|
| 26 |
"""
|
| 27 |
-
Download video from URL
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
Args:
|
| 30 |
-
url:
|
| 31 |
output_dir: Directory to save the video
|
| 32 |
-
cookies_path: Optional
|
| 33 |
-
|
| 34 |
|
| 35 |
Returns:
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
ydl_opts = {
|
| 45 |
"format": "best[height<=1080]/best",
|
| 46 |
"quiet": True,
|
| 47 |
"no_warnings": True,
|
| 48 |
-
# Use video ID in filename — avoids special-char title issues
|
| 49 |
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
| 50 |
"socket_timeout": 60,
|
| 51 |
"retries": 10,
|
|
@@ -61,52 +194,36 @@ class VideoDownloader:
|
|
| 61 |
),
|
| 62 |
"Accept-Language": "en-US,en;q=0.9",
|
| 63 |
},
|
| 64 |
-
# tv_embedded + mweb are far less aggressively blocked on datacenter IPs
|
| 65 |
-
# than android_embedded (which YouTube actively rate-limits on server IPs)
|
| 66 |
"extractor_args": {
|
| 67 |
"youtube": {"player_client": ["tv_embedded", "mweb", "web"]},
|
| 68 |
},
|
| 69 |
}
|
| 70 |
-
|
| 71 |
-
# Cookies bypass YouTube's datacenter IP restrictions
|
| 72 |
if cookies_path:
|
| 73 |
ydl_opts["cookiefile"] = cookies_path
|
| 74 |
self.logger.info(f"Using cookies file: {cookies_path}")
|
| 75 |
|
| 76 |
-
# Run yt-dlp in thread pool to avoid blocking
|
| 77 |
loop = asyncio.get_event_loop()
|
| 78 |
result = await loop.run_in_executor(
|
| 79 |
None, self._download_with_ytdlp, url, ydl_opts
|
| 80 |
)
|
| 81 |
-
|
| 82 |
-
self.logger.info(f"Successfully downloaded video: {result['title']}")
|
| 83 |
return result
|
| 84 |
|
| 85 |
except Exception as e:
|
| 86 |
-
self.logger.error(f"
|
| 87 |
-
raise Exception(f"Video download failed: {
|
| 88 |
|
| 89 |
def _download_with_ytdlp(self, url: str, opts: Dict) -> Dict[str, any]:
|
| 90 |
-
"""
|
| 91 |
-
Helper method to run yt-dlp download (blocking operation)
|
| 92 |
-
|
| 93 |
-
Args:
|
| 94 |
-
url: Video URL
|
| 95 |
-
opts: yt-dlp options
|
| 96 |
-
|
| 97 |
-
Returns:
|
| 98 |
-
Dictionary with video metadata
|
| 99 |
-
"""
|
| 100 |
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 101 |
info = ydl.extract_info(url, download=True)
|
| 102 |
|
| 103 |
-
# Find the downloaded file (outtmpl uses %(id)s now)
|
| 104 |
video_id = info.get("id", "video")
|
| 105 |
video_ext = info.get("ext", "mp4")
|
| 106 |
-
video_path = Path(
|
|
|
|
|
|
|
| 107 |
|
| 108 |
if not video_path.exists():
|
| 109 |
-
# Fallback: scan directory for recently created video files
|
| 110 |
dl_dir = Path(opts["outtmpl"].split("%(")[0])
|
| 111 |
files = sorted(dl_dir.glob(f"{video_id}.*"), key=lambda p: p.stat().st_mtime)
|
| 112 |
if not files:
|
|
@@ -123,20 +240,11 @@ class VideoDownloader:
|
|
| 123 |
"thumbnail_url": info.get("thumbnail", ""),
|
| 124 |
}
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
Args:
|
| 131 |
-
video_path: Path to video file
|
| 132 |
-
output_dir: Directory to save audio
|
| 133 |
-
|
| 134 |
-
Returns:
|
| 135 |
-
Path to extracted WAV file
|
| 136 |
|
| 137 |
-
|
| 138 |
-
Exception: If extraction fails
|
| 139 |
-
"""
|
| 140 |
try:
|
| 141 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 142 |
audio_path = output_dir / f"{video_path.stem}.wav"
|
|
@@ -146,27 +254,19 @@ class VideoDownloader:
|
|
| 146 |
None, self._extract_audio_sync, str(video_path), str(audio_path)
|
| 147 |
)
|
| 148 |
|
| 149 |
-
self.logger.info(f"
|
| 150 |
return audio_path
|
| 151 |
|
| 152 |
except Exception as e:
|
| 153 |
-
self.logger.error(f"
|
| 154 |
-
raise Exception(f"Audio extraction failed: {
|
| 155 |
|
| 156 |
def _extract_audio_sync(self, video_path: str, audio_path: str) -> None:
|
| 157 |
-
"""
|
| 158 |
-
Synchronous audio extraction using ffmpeg-python
|
| 159 |
-
|
| 160 |
-
Args:
|
| 161 |
-
video_path: Path to input video
|
| 162 |
-
audio_path: Path to output audio
|
| 163 |
-
"""
|
| 164 |
try:
|
| 165 |
stream = ffmpeg.input(video_path)
|
| 166 |
stream = ffmpeg.output(stream, audio_path, acodec="pcm_s16le", ac=1, ar=16000)
|
| 167 |
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True, quiet=True)
|
| 168 |
-
except ffmpeg.Error
|
| 169 |
-
# Try alternative approach
|
| 170 |
import subprocess
|
| 171 |
subprocess.run(
|
| 172 |
["ffmpeg", "-i", video_path, "-acodec", "pcm_s16le", "-ac", "1", "-ar", "16000", audio_path],
|
|
|
|
| 1 |
"""
|
| 2 |
+
Video downloader using yt-dlp for YouTube, TikTok, Instagram, and direct uploads.
|
| 3 |
+
|
| 4 |
+
YouTube on HuggingFace Spaces fails with DNS errors because the datacenter IP is
|
| 5 |
+
blocked at the network level by YouTube. Fix: route through Invidious (an open
|
| 6 |
+
YouTube proxy) — no youtube.com DNS lookup needed.
|
| 7 |
"""
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
import logging
|
| 11 |
+
import re
|
| 12 |
from pathlib import Path
|
| 13 |
+
from typing import Dict, List, Optional
|
| 14 |
|
| 15 |
import ffmpeg
|
| 16 |
+
import httpx
|
| 17 |
import yt_dlp
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
# Public Invidious instances — tried in order, first success wins.
|
| 22 |
+
# List sourced from https://docs.invidious.io/instances/ (updated 2025)
|
| 23 |
+
INVIDIOUS_INSTANCES: List[str] = [
|
| 24 |
+
"https://inv.nadeko.net",
|
| 25 |
+
"https://invidious.privacydev.net",
|
| 26 |
+
"https://iv.melmac.space",
|
| 27 |
+
"https://yt.cdaut.de",
|
| 28 |
+
"https://invidious.perennialte.ch",
|
| 29 |
+
"https://invidious.nerdvpn.de",
|
| 30 |
+
"https://yewtu.be",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
_YT_ID_RE = re.compile(
|
| 34 |
+
r"(?:youtube\.com/(?:watch\?.*?v=|embed/|shorts/)|youtu\.be/)([a-zA-Z0-9_-]{11})"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_youtube_id(url: str) -> Optional[str]:
|
| 39 |
+
m = _YT_ID_RE.search(url)
|
| 40 |
+
return m.group(1) if m else None
|
| 41 |
+
|
| 42 |
|
| 43 |
class VideoDownloader:
|
| 44 |
+
"""Download videos from various sources using yt-dlp (YouTube via Invidious proxy)"""
|
| 45 |
|
| 46 |
def __init__(self):
|
|
|
|
| 47 |
self.logger = logger
|
| 48 |
|
| 49 |
+
# ------------------------------------------------------------------
|
| 50 |
+
# Public API
|
| 51 |
+
# ------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
async def download(
|
| 54 |
self, url: str, output_dir: Path, cookies_path: Optional[str] = None
|
| 55 |
) -> Dict[str, any]:
|
| 56 |
"""
|
| 57 |
+
Download video from URL.
|
| 58 |
+
|
| 59 |
+
For YouTube URLs the download is routed through an Invidious instance so
|
| 60 |
+
that youtube.com is never contacted directly (HuggingFace datacenter IPs
|
| 61 |
+
are blocked by YouTube at the DNS/network level).
|
| 62 |
|
| 63 |
Args:
|
| 64 |
+
url: YouTube / TikTok / Instagram / direct URL
|
| 65 |
output_dir: Directory to save the video
|
| 66 |
+
cookies_path: Optional Netscape cookies.txt path (passed to yt-dlp
|
| 67 |
+
for non-YouTube sources or as extra auth)
|
| 68 |
|
| 69 |
Returns:
|
| 70 |
+
Dict with keys: path, title, duration, thumbnail_url
|
| 71 |
+
"""
|
| 72 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
yt_id = _extract_youtube_id(url)
|
| 75 |
+
if yt_id:
|
| 76 |
+
self.logger.info(f"YouTube video detected (id={yt_id}), using Invidious proxy")
|
| 77 |
+
try:
|
| 78 |
+
return await asyncio.get_event_loop().run_in_executor(
|
| 79 |
+
None, self._download_via_invidious, yt_id, output_dir
|
| 80 |
+
)
|
| 81 |
+
except Exception as inv_err:
|
| 82 |
+
self.logger.warning(
|
| 83 |
+
f"Invidious download failed ({inv_err}), falling back to yt-dlp"
|
| 84 |
+
)
|
| 85 |
+
# Fall through to yt-dlp as last resort
|
| 86 |
+
|
| 87 |
+
# Non-YouTube or Invidious-failed fallback — use yt-dlp
|
| 88 |
+
return await self._download_ytdlp(url, output_dir, cookies_path)
|
| 89 |
|
| 90 |
+
# ------------------------------------------------------------------
|
| 91 |
+
# Invidious path (YouTube only)
|
| 92 |
+
# ------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
def _download_via_invidious(self, video_id: str, output_dir: Path) -> Dict[str, any]:
|
| 95 |
"""
|
| 96 |
+
Fetch video info from Invidious API and download the stream through the
|
| 97 |
+
Invidious proxy (local=true), so youtube.com is never contacted.
|
| 98 |
+
"""
|
| 99 |
+
headers = {
|
| 100 |
+
"User-Agent": (
|
| 101 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 102 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 103 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
last_error = None
|
| 108 |
+
for instance in INVIDIOUS_INSTANCES:
|
| 109 |
+
try:
|
| 110 |
+
self.logger.info(f"Trying Invidious instance: {instance}")
|
| 111 |
+
|
| 112 |
+
# 1. Fetch video metadata
|
| 113 |
+
api_url = f"{instance}/api/v1/videos/{video_id}?fields=title,lengthSeconds,formatStreams,adaptiveFormats"
|
| 114 |
+
with httpx.Client(timeout=30, follow_redirects=True, headers=headers) as client:
|
| 115 |
+
resp = client.get(api_url)
|
| 116 |
+
if resp.status_code != 200:
|
| 117 |
+
self.logger.debug(f"{instance} returned HTTP {resp.status_code}")
|
| 118 |
+
continue
|
| 119 |
+
data = resp.json()
|
| 120 |
+
|
| 121 |
+
title = data.get("title", "Unknown")
|
| 122 |
+
duration = int(data.get("lengthSeconds", 0))
|
| 123 |
+
|
| 124 |
+
# 2. Pick best combined stream (video+audio, no muxing needed)
|
| 125 |
+
streams = data.get("formatStreams", [])
|
| 126 |
+
if not streams:
|
| 127 |
+
self.logger.debug(f"{instance} returned no formatStreams")
|
| 128 |
+
continue
|
| 129 |
+
|
| 130 |
+
# Sort by height descending, cap at 1080
|
| 131 |
+
def _height(s):
|
| 132 |
+
return int(s.get("resolution", "0p").rstrip("p") or 0)
|
| 133 |
+
|
| 134 |
+
streams_ok = [s for s in streams if _height(s) <= 1080]
|
| 135 |
+
best = max(streams_ok or streams, key=_height)
|
| 136 |
+
itag = best.get("itag", "")
|
| 137 |
+
|
| 138 |
+
# 3. Download through Invidious proxy (avoids youtube.com entirely)
|
| 139 |
+
stream_url = f"{instance}/latest_version?id={video_id}&itag={itag}&local=true"
|
| 140 |
+
output_path = output_dir / f"{video_id}.mp4"
|
| 141 |
+
|
| 142 |
+
self.logger.info(f"Downloading via {instance} (itag={itag}, res={best.get('resolution')})")
|
| 143 |
+
with httpx.Client(timeout=300, follow_redirects=True, headers=headers) as client:
|
| 144 |
+
with client.stream("GET", stream_url) as r:
|
| 145 |
+
r.raise_for_status()
|
| 146 |
+
with open(output_path, "wb") as fh:
|
| 147 |
+
for chunk in r.iter_bytes(chunk_size=1024 * 1024):
|
| 148 |
+
fh.write(chunk)
|
| 149 |
+
|
| 150 |
+
if not output_path.exists() or output_path.stat().st_size < 1024:
|
| 151 |
+
raise Exception("Downloaded file is empty or missing")
|
| 152 |
+
|
| 153 |
+
self.logger.info(f"Invidious download complete: {output_path}")
|
| 154 |
+
return {
|
| 155 |
+
"path": str(output_path),
|
| 156 |
+
"title": title,
|
| 157 |
+
"duration": duration,
|
| 158 |
+
"thumbnail_url": f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg",
|
| 159 |
+
}
|
| 160 |
|
| 161 |
+
except Exception as e:
|
| 162 |
+
last_error = e
|
| 163 |
+
self.logger.warning(f"{instance} failed: {e}")
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
raise Exception(
|
| 167 |
+
f"All Invidious instances failed for video {video_id}. Last error: {last_error}"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
# yt-dlp path (TikTok, Instagram, non-YouTube, fallback)
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
async def _download_ytdlp(
|
| 175 |
+
self, url: str, output_dir: Path, cookies_path: Optional[str] = None
|
| 176 |
+
) -> Dict[str, any]:
|
| 177 |
+
try:
|
| 178 |
ydl_opts = {
|
| 179 |
"format": "best[height<=1080]/best",
|
| 180 |
"quiet": True,
|
| 181 |
"no_warnings": True,
|
|
|
|
| 182 |
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
| 183 |
"socket_timeout": 60,
|
| 184 |
"retries": 10,
|
|
|
|
| 194 |
),
|
| 195 |
"Accept-Language": "en-US,en;q=0.9",
|
| 196 |
},
|
|
|
|
|
|
|
| 197 |
"extractor_args": {
|
| 198 |
"youtube": {"player_client": ["tv_embedded", "mweb", "web"]},
|
| 199 |
},
|
| 200 |
}
|
|
|
|
|
|
|
| 201 |
if cookies_path:
|
| 202 |
ydl_opts["cookiefile"] = cookies_path
|
| 203 |
self.logger.info(f"Using cookies file: {cookies_path}")
|
| 204 |
|
|
|
|
| 205 |
loop = asyncio.get_event_loop()
|
| 206 |
result = await loop.run_in_executor(
|
| 207 |
None, self._download_with_ytdlp, url, ydl_opts
|
| 208 |
)
|
| 209 |
+
self.logger.info(f"yt-dlp download complete: {result['title']}")
|
|
|
|
| 210 |
return result
|
| 211 |
|
| 212 |
except Exception as e:
|
| 213 |
+
self.logger.error(f"yt-dlp failed for {url}: {e}")
|
| 214 |
+
raise Exception(f"Video download failed: {e}")
|
| 215 |
|
| 216 |
def _download_with_ytdlp(self, url: str, opts: Dict) -> Dict[str, any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 218 |
info = ydl.extract_info(url, download=True)
|
| 219 |
|
|
|
|
| 220 |
video_id = info.get("id", "video")
|
| 221 |
video_ext = info.get("ext", "mp4")
|
| 222 |
+
video_path = Path(
|
| 223 |
+
opts["outtmpl"].replace("%(id)s", video_id).replace("%(ext)s", video_ext)
|
| 224 |
+
)
|
| 225 |
|
| 226 |
if not video_path.exists():
|
|
|
|
| 227 |
dl_dir = Path(opts["outtmpl"].split("%(")[0])
|
| 228 |
files = sorted(dl_dir.glob(f"{video_id}.*"), key=lambda p: p.stat().st_mtime)
|
| 229 |
if not files:
|
|
|
|
| 240 |
"thumbnail_url": info.get("thumbnail", ""),
|
| 241 |
}
|
| 242 |
|
| 243 |
+
# ------------------------------------------------------------------
|
| 244 |
+
# Audio extraction (unchanged)
|
| 245 |
+
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
+
async def extract_audio(self, video_path: Path, output_dir: Path) -> Path:
|
|
|
|
|
|
|
| 248 |
try:
|
| 249 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 250 |
audio_path = output_dir / f"{video_path.stem}.wav"
|
|
|
|
| 254 |
None, self._extract_audio_sync, str(video_path), str(audio_path)
|
| 255 |
)
|
| 256 |
|
| 257 |
+
self.logger.info(f"Audio extracted to {audio_path}")
|
| 258 |
return audio_path
|
| 259 |
|
| 260 |
except Exception as e:
|
| 261 |
+
self.logger.error(f"Audio extraction failed: {e}")
|
| 262 |
+
raise Exception(f"Audio extraction failed: {e}")
|
| 263 |
|
| 264 |
def _extract_audio_sync(self, video_path: str, audio_path: str) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
try:
|
| 266 |
stream = ffmpeg.input(video_path)
|
| 267 |
stream = ffmpeg.output(stream, audio_path, acodec="pcm_s16le", ac=1, ar=16000)
|
| 268 |
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True, quiet=True)
|
| 269 |
+
except ffmpeg.Error:
|
|
|
|
| 270 |
import subprocess
|
| 271 |
subprocess.run(
|
| 272 |
["ffmpeg", "-i", video_path, "-acodec", "pcm_s16le", "-ac", "1", "-ar", "16000", audio_path],
|