ismdrobiul489 commited on
Commit
28bbf81
Β·
1 Parent(s): 30fb462

feat: YouTube to HF uploader - folder management, video/audio modes, category naming, text_story UI update

Browse files
modules/shared/__init__.py CHANGED
@@ -1,3 +1,23 @@
1
  """
2
  Shared utilities and services for NCAkit modules
3
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Shared utilities and services for NCAkit modules
3
  """
4
+
5
+ import logging
6
+ from fastapi import FastAPI
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Module metadata
11
+ MODULE_NAME = "shared"
12
+ MODULE_PREFIX = "/api/utils"
13
+ MODULE_DESCRIPTION = "Shared utilities including YouTube to HF uploader"
14
+
15
+
16
+ def register(app: FastAPI, config=None):
17
+ """Register the shared module with FastAPI."""
18
+ from .router import router
19
+
20
+ # Include router
21
+ app.include_router(router)
22
+
23
+ logger.info("shared module registered (utilities endpoints available)")
modules/shared/router.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared Utilities Router.
3
+ Provides common API endpoints for media management.
4
+ """
5
+
6
+ import logging
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel, Field
9
+ from typing import Optional, List
10
+
11
+ from .services.youtube_uploader import get_uploader
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ router = APIRouter(prefix="/api/utils", tags=["Utilities"])
16
+
17
+
18
+ # ============================================
19
+ # SCHEMAS
20
+ # ============================================
21
+
22
+ class FolderCreateRequest(BaseModel):
23
+ """Request to create a new folder."""
24
+ name: str = Field(..., min_length=1, max_length=50, description="Folder name")
25
+
26
+
27
+ class YouTubeUploadRequest(BaseModel):
28
+ """Request to download and upload YouTube content."""
29
+ url: str = Field(..., description="YouTube video or playlist URL")
30
+ folder: str = Field(..., description="Target folder in HF Dataset")
31
+ format: str = Field(default="mp4", description="Format: mp4 (video) or mp3 (audio)")
32
+ category: Optional[str] = Field(
33
+ default=None,
34
+ description="Category name for audio files (e.g., 'emotional', 'energetic')"
35
+ )
36
+
37
+
38
+ # ============================================
39
+ # ENDPOINTS
40
+ # ============================================
41
+
42
+ @router.get("/hf-folders", summary="List HF Dataset folders")
43
+ async def list_hf_folders():
44
+ """Get all folders in HuggingFace Dataset."""
45
+ uploader = get_uploader()
46
+
47
+ if not uploader.enabled:
48
+ raise HTTPException(
49
+ status_code=503,
50
+ detail="Uploader not available. Check HF_REPO and HF_TOKEN."
51
+ )
52
+
53
+ folders = uploader.list_folders()
54
+ return {"folders": folders}
55
+
56
+
57
+ @router.post("/hf-folders", summary="Create new folder")
58
+ async def create_hf_folder(request: FolderCreateRequest):
59
+ """Create a new folder in HuggingFace Dataset."""
60
+ uploader = get_uploader()
61
+
62
+ if not uploader.enabled:
63
+ raise HTTPException(status_code=503, detail="Uploader not available")
64
+
65
+ success = uploader.create_folder(request.name)
66
+
67
+ if not success:
68
+ raise HTTPException(status_code=500, detail="Failed to create folder")
69
+
70
+ return {"success": True, "folder": request.name}
71
+
72
+
73
+ @router.post("/youtube-upload", summary="Upload YouTube content to HF")
74
+ async def upload_youtube_content(request: YouTubeUploadRequest):
75
+ """
76
+ Download YouTube video/playlist and upload to HF Dataset.
77
+
78
+ - **url**: YouTube video or playlist URL
79
+ - **folder**: Target folder (e.g., 'gameplay_backgrounds', 'music')
80
+ - **format**: 'mp4' for video, 'mp3' for audio
81
+ - **category**: For audio, category name like 'emotional', 'energetic'
82
+ """
83
+ uploader = get_uploader()
84
+
85
+ if not uploader.enabled:
86
+ raise HTTPException(status_code=503, detail="Uploader not available")
87
+
88
+ if request.format == "mp3":
89
+ # Audio mode
90
+ category = request.category or "music"
91
+ result = uploader.download_audio(
92
+ url=request.url,
93
+ folder=request.folder,
94
+ category=category
95
+ )
96
+ else:
97
+ # Video mode
98
+ result = uploader.download_video(
99
+ url=request.url,
100
+ folder=request.folder
101
+ )
102
+
103
+ if not result.get("success", False):
104
+ raise HTTPException(status_code=500, detail=result.get("error", "Upload failed"))
105
+
106
+ return result
107
+
108
+
109
+ @router.get("/health", summary="Check uploader status")
110
+ async def uploader_health():
111
+ """Check if YouTube uploader is available."""
112
+ uploader = get_uploader()
113
+
114
+ return {
115
+ "enabled": uploader.enabled,
116
+ "repo_id": uploader.repo_id if uploader.enabled else None
117
+ }
modules/shared/services/youtube_uploader.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ YouTube to HuggingFace Media Uploader.
3
+ Downloads YouTube videos/playlists and uploads to HF Dataset.
4
+ No local storage - temp files deleted immediately.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ import tempfile
10
+ import shutil
11
+ from typing import List, Optional, Dict
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Try to import dependencies
17
+ try:
18
+ import yt_dlp
19
+ YTDLP_AVAILABLE = True
20
+ except ImportError:
21
+ YTDLP_AVAILABLE = False
22
+ logger.warning("yt-dlp not installed. YouTube uploads disabled.")
23
+
24
+ try:
25
+ from huggingface_hub import HfApi, list_repo_files
26
+ HF_HUB_AVAILABLE = True
27
+ except ImportError:
28
+ HF_HUB_AVAILABLE = False
29
+ logger.warning("huggingface_hub not installed.")
30
+
31
+
32
+ class YouTubeToHFUploader:
33
+ """
34
+ Downloads YouTube videos/audio and uploads to HuggingFace Dataset.
35
+
36
+ Features:
37
+ - Single video or playlist download
38
+ - MP4 (video) or MP3 (audio) format
39
+ - Custom category naming for audio
40
+ - Direct upload to HF, no local storage
41
+ """
42
+
43
+ def __init__(self, repo_id: str = None, token: str = None):
44
+ """
45
+ Initialize uploader.
46
+
47
+ Args:
48
+ repo_id: HF repo ID (e.g., "username/dataset")
49
+ token: HF token with write access
50
+ """
51
+ self.repo_id = repo_id or os.getenv("HF_REPO", "")
52
+ self.token = token or os.getenv("HF_TOKEN", "")
53
+ self.api = None
54
+
55
+ if not self.repo_id or not self.token:
56
+ logger.warning("YouTubeUploader: HF_REPO or HF_TOKEN not set")
57
+ self.enabled = False
58
+ return
59
+
60
+ if not YTDLP_AVAILABLE or not HF_HUB_AVAILABLE:
61
+ self.enabled = False
62
+ return
63
+
64
+ self.enabled = True
65
+ self.api = HfApi(token=self.token)
66
+ logger.info(f"YouTubeUploader: Initialized for {self.repo_id}")
67
+
68
+ def list_folders(self) -> List[str]:
69
+ """List all folders in HF Dataset."""
70
+ if not self.enabled:
71
+ return []
72
+
73
+ try:
74
+ all_files = list_repo_files(
75
+ repo_id=self.repo_id,
76
+ repo_type="dataset"
77
+ )
78
+
79
+ # Extract unique folder names
80
+ folders = set()
81
+ for f in all_files:
82
+ if "/" in f:
83
+ folder = f.split("/")[0]
84
+ folders.add(folder)
85
+
86
+ return sorted(list(folders))
87
+
88
+ except Exception as e:
89
+ logger.error(f"Failed to list folders: {e}")
90
+ return []
91
+
92
+ def create_folder(self, folder_name: str) -> bool:
93
+ """
94
+ Create a new folder in HF Dataset.
95
+
96
+ Args:
97
+ folder_name: Name of folder to create
98
+
99
+ Returns:
100
+ True if successful
101
+ """
102
+ if not self.enabled:
103
+ return False
104
+
105
+ try:
106
+ # Create placeholder file to create folder
107
+ placeholder = f"# {folder_name}\n\nFolder for media files."
108
+
109
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
110
+ f.write(placeholder)
111
+ temp_path = f.name
112
+
113
+ self.api.upload_file(
114
+ path_or_fileobj=temp_path,
115
+ path_in_repo=f"{folder_name}/README.md",
116
+ repo_id=self.repo_id,
117
+ repo_type="dataset"
118
+ )
119
+
120
+ os.remove(temp_path)
121
+ logger.info(f"Created folder: {folder_name}")
122
+ return True
123
+
124
+ except Exception as e:
125
+ logger.error(f"Failed to create folder: {e}")
126
+ return False
127
+
128
+ def _get_next_index(self, folder: str, category: str) -> int:
129
+ """Get next available index for category files."""
130
+ try:
131
+ all_files = list_repo_files(
132
+ repo_id=self.repo_id,
133
+ repo_type="dataset"
134
+ )
135
+
136
+ # Find existing files with this category
137
+ existing = [
138
+ f for f in all_files
139
+ if f.startswith(f"{folder}/{category}_") and f.endswith(".mp3")
140
+ ]
141
+
142
+ if not existing:
143
+ return 1
144
+
145
+ # Extract highest index
146
+ indices = []
147
+ for f in existing:
148
+ try:
149
+ # Extract number from filename like "emotional_003.mp3"
150
+ name = f.split("/")[-1]
151
+ num = int(name.replace(f"{category}_", "").replace(".mp3", ""))
152
+ indices.append(num)
153
+ except:
154
+ pass
155
+
156
+ return max(indices) + 1 if indices else 1
157
+
158
+ except:
159
+ return 1
160
+
161
+ def download_video(self, url: str, folder: str) -> Dict:
162
+ """
163
+ Download YouTube video and upload to HF.
164
+
165
+ Args:
166
+ url: YouTube video or playlist URL
167
+ folder: Target folder in HF Dataset
168
+
169
+ Returns:
170
+ Dict with results
171
+ """
172
+ if not self.enabled:
173
+ return {"success": False, "error": "Uploader not enabled"}
174
+
175
+ temp_dir = tempfile.mkdtemp(prefix="yt_video_")
176
+ results = {"success": True, "uploaded": [], "errors": []}
177
+
178
+ try:
179
+ ydl_opts = {
180
+ 'format': 'best[height<=720][ext=mp4]/best[height<=720]/best',
181
+ 'outtmpl': os.path.join(temp_dir, '%(id)s.%(ext)s'),
182
+ 'quiet': True,
183
+ 'no_warnings': True,
184
+ }
185
+
186
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
187
+ # Get video info
188
+ info = ydl.extract_info(url, download=True)
189
+
190
+ # Handle playlist
191
+ entries = info.get('entries', [info])
192
+
193
+ for entry in entries:
194
+ if not entry:
195
+ continue
196
+
197
+ video_id = entry.get('id', 'unknown')
198
+
199
+ # Find downloaded file
200
+ for ext in ['mp4', 'webm', 'mkv']:
201
+ local_path = os.path.join(temp_dir, f"{video_id}.{ext}")
202
+ if os.path.exists(local_path):
203
+ break
204
+ else:
205
+ results["errors"].append(f"File not found for {video_id}")
206
+ continue
207
+
208
+ # Upload to HF
209
+ try:
210
+ path_in_repo = f"{folder}/{video_id}.mp4"
211
+ self.api.upload_file(
212
+ path_or_fileobj=local_path,
213
+ path_in_repo=path_in_repo,
214
+ repo_id=self.repo_id,
215
+ repo_type="dataset"
216
+ )
217
+
218
+ cloud_url = f"https://huggingface.co/datasets/{self.repo_id}/resolve/main/{path_in_repo}"
219
+ results["uploaded"].append({
220
+ "id": video_id,
221
+ "title": entry.get('title', video_id),
222
+ "url": cloud_url
223
+ })
224
+
225
+ # Delete local file immediately
226
+ os.remove(local_path)
227
+ logger.info(f"Uploaded: {video_id}")
228
+
229
+ except Exception as e:
230
+ results["errors"].append(f"{video_id}: {str(e)}")
231
+
232
+ except Exception as e:
233
+ results["success"] = False
234
+ results["error"] = str(e)
235
+ logger.error(f"Download failed: {e}")
236
+
237
+ finally:
238
+ # Cleanup temp directory
239
+ shutil.rmtree(temp_dir, ignore_errors=True)
240
+
241
+ return results
242
+
243
+ def download_audio(self, url: str, folder: str, category: str = "music") -> Dict:
244
+ """
245
+ Download YouTube audio as MP3 and upload to HF.
246
+
247
+ Args:
248
+ url: YouTube video or playlist URL
249
+ folder: Target folder in HF Dataset
250
+ category: Category name for files (e.g., "emotional", "energetic")
251
+
252
+ Returns:
253
+ Dict with results
254
+ """
255
+ if not self.enabled:
256
+ return {"success": False, "error": "Uploader not enabled"}
257
+
258
+ temp_dir = tempfile.mkdtemp(prefix="yt_audio_")
259
+ results = {"success": True, "uploaded": [], "errors": []}
260
+
261
+ try:
262
+ ydl_opts = {
263
+ 'format': 'bestaudio/best',
264
+ 'outtmpl': os.path.join(temp_dir, '%(id)s.%(ext)s'),
265
+ 'quiet': True,
266
+ 'no_warnings': True,
267
+ 'postprocessors': [{
268
+ 'key': 'FFmpegExtractAudio',
269
+ 'preferredcodec': 'mp3',
270
+ 'preferredquality': '192',
271
+ }],
272
+ }
273
+
274
+ # Get starting index for category
275
+ next_index = self._get_next_index(folder, category)
276
+
277
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
278
+ info = ydl.extract_info(url, download=True)
279
+ entries = info.get('entries', [info])
280
+
281
+ for entry in entries:
282
+ if not entry:
283
+ continue
284
+
285
+ video_id = entry.get('id', 'unknown')
286
+ local_path = os.path.join(temp_dir, f"{video_id}.mp3")
287
+
288
+ if not os.path.exists(local_path):
289
+ results["errors"].append(f"MP3 not found for {video_id}")
290
+ continue
291
+
292
+ # Upload with category naming
293
+ try:
294
+ filename = f"{category}_{next_index:03d}.mp3"
295
+ path_in_repo = f"{folder}/{filename}"
296
+
297
+ self.api.upload_file(
298
+ path_or_fileobj=local_path,
299
+ path_in_repo=path_in_repo,
300
+ repo_id=self.repo_id,
301
+ repo_type="dataset"
302
+ )
303
+
304
+ cloud_url = f"https://huggingface.co/datasets/{self.repo_id}/resolve/main/{path_in_repo}"
305
+ results["uploaded"].append({
306
+ "id": video_id,
307
+ "title": entry.get('title', video_id),
308
+ "filename": filename,
309
+ "url": cloud_url
310
+ })
311
+
312
+ # Delete local file immediately
313
+ os.remove(local_path)
314
+ logger.info(f"Uploaded: {filename}")
315
+ next_index += 1
316
+
317
+ except Exception as e:
318
+ results["errors"].append(f"{video_id}: {str(e)}")
319
+
320
+ except Exception as e:
321
+ results["success"] = False
322
+ results["error"] = str(e)
323
+ logger.error(f"Audio download failed: {e}")
324
+
325
+ finally:
326
+ shutil.rmtree(temp_dir, ignore_errors=True)
327
+
328
+ return results
329
+
330
+
331
+ # Singleton instance
332
+ _uploader: Optional[YouTubeToHFUploader] = None
333
+
334
+
335
+ def get_uploader() -> YouTubeToHFUploader:
336
+ """Get or create uploader instance."""
337
+ global _uploader
338
+ if _uploader is None:
339
+ _uploader = YouTubeToHFUploader()
340
+ return _uploader
modules/text_story/router.py CHANGED
@@ -133,13 +133,17 @@ async def generate_text_story_video(job_id: str, request: TextStoryRequest):
133
  )
134
  if cloud_url:
135
  logger.info(f"TextStory: Uploaded to HF: {cloud_url}")
 
 
 
 
136
  except Exception as e:
137
- logger.warning(f"TextStory: HF upload failed: {e}")
138
 
139
  # Cleanup temp
140
  shutil.rmtree(temp_dir, ignore_errors=True)
141
 
142
- # Use cloud URL if available, otherwise local
143
  video_url = cloud_url or f"/api/text-story/{job_id}/video"
144
  update_job(job_id, "ready", 100, "Complete!", video_url=video_url)
145
  logger.info(f"TextStory: Job {job_id} completed successfully")
 
133
  )
134
  if cloud_url:
135
  logger.info(f"TextStory: Uploaded to HF: {cloud_url}")
136
+ # Delete local file after successful upload
137
+ if os.path.exists(final_path):
138
+ os.remove(final_path)
139
+ logger.info(f"TextStory: Deleted local file after HF upload")
140
  except Exception as e:
141
+ logger.warning(f"TextStory: HF upload failed, keeping local file: {e}")
142
 
143
  # Cleanup temp
144
  shutil.rmtree(temp_dir, ignore_errors=True)
145
 
146
+ # Use cloud URL if available, otherwise local endpoint for direct streaming
147
  video_url = cloud_url or f"/api/text-story/{job_id}/video"
148
  update_job(job_id, "ready", 100, "Complete!", video_url=video_url)
149
  logger.info(f"TextStory: Job {job_id} completed successfully")
modules/text_story/services/renderer.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Chat UI Renderer for Text Story module.
3
- Creates dating-app style chat bubbles and UI.
4
  """
5
 
6
  import os
@@ -15,48 +15,61 @@ logger = logging.getLogger(__name__)
15
  CANVAS_WIDTH = 1080
16
  CANVAS_HEIGHT = 1920
17
 
18
- # Colors (Dating app style)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  COLORS = {
20
- # Header gradient (purple/blue)
21
- "header_start": (138, 43, 226), # Blue-violet
22
- "header_end": (75, 0, 130), # Indigo
23
 
24
- # Bubbles
25
- "bubble_other": (245, 240, 235), # Cream/beige for left (other person)
26
- "bubble_user": (255, 255, 255), # White for right (user)
27
 
28
  # Text
29
- "text_dark": (30, 30, 30), # Dark text on light bubbles
30
- "text_white": (255, 255, 255), # White text on header
31
- "text_gray": (120, 120, 120), # Gray for timestamps
32
- "text_online": (50, 205, 50), # Green for "Online"
33
 
34
  # Background
35
- "chat_bg": (240, 240, 245), # Light gray chat area
36
  }
37
 
38
  # UI Measurements
39
  UI = {
40
- "header_height": 180, # Taller header for avatar
41
- "margin_side": 40,
42
- "bubble_max_width_ratio": 0.72,
43
- "bubble_padding_h": 20,
44
- "bubble_padding_v": 14,
45
- "bubble_radius": 24,
46
- "bubble_gap": 20,
47
- "font_size": 36,
48
- "header_name_size": 48,
49
- "timestamp_size": 22,
50
- "avatar_size": 90,
51
- "max_visible_messages": 7,
52
- "chat_area_top": 220, # Where chat area starts (below header)
53
  }
54
 
55
 
56
  class ChatRenderer:
57
  """
58
- Renders dating-app style chat UI frames.
59
- Fixed header with gradient, scrolling messages.
60
  """
61
 
62
  def __init__(self,
@@ -69,13 +82,9 @@ class ChatRenderer:
69
 
70
  # Load fonts
71
  self.font = self._load_font(UI["font_size"])
72
- self.font_name = self._load_font(UI["header_name_size"])
73
- self.font_timestamp = self._load_font(UI["timestamp_size"])
74
- self.font_small = self._load_font(24)
75
-
76
- # Current timestamp (random for realism)
77
- self.base_hour = random.randint(10, 22)
78
- self.base_minute = random.randint(10, 45)
79
 
80
  def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
81
  """Load font with fallback."""
@@ -118,18 +127,12 @@ class ChatRenderer:
118
 
119
  return lines if lines else [text]
120
 
121
- def _get_timestamp(self, msg_index: int) -> str:
122
- """Generate realistic timestamp."""
123
- minute = (self.base_minute + msg_index * 2) % 60
124
- hour = self.base_hour + ((self.base_minute + msg_index * 2) // 60)
125
- return f"{hour % 24:02d}:{minute:02d}"
126
-
127
  def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]:
128
  """Calculate bubble size based on text."""
129
- max_text_width = int(CANVAS_WIDTH * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2
130
  lines = self._wrap_text(text, max_text_width)
131
 
132
- line_height = self.font.getbbox("Ay")[3] + 6
133
  text_height = line_height * len(lines)
134
 
135
  max_line_width = 0
@@ -137,107 +140,94 @@ class ChatRenderer:
137
  bbox = self.font.getbbox(line)
138
  max_line_width = max(max_line_width, bbox[2] - bbox[0])
139
 
140
- # Add padding + space for timestamp
141
- bubble_width = max_line_width + UI["bubble_padding_h"] * 2 + 80 # Extra for timestamp
142
  bubble_height = text_height + UI["bubble_padding_v"] * 2
143
 
144
  return bubble_width, bubble_height, lines
145
 
146
- def _draw_gradient_header(self, img: Image.Image, draw: ImageDraw.Draw):
147
- """Draw gradient header with avatar and name."""
148
- # Create gradient
149
- for y in range(UI["header_height"]):
150
- ratio = y / UI["header_height"]
151
- r = int(COLORS["header_start"][0] * (1 - ratio) + COLORS["header_end"][0] * ratio)
152
- g = int(COLORS["header_start"][1] * (1 - ratio) + COLORS["header_end"][1] * ratio)
153
- b = int(COLORS["header_start"][2] * (1 - ratio) + COLORS["header_end"][2] * ratio)
154
- draw.line([(0, y), (CANVAS_WIDTH, y)], fill=(r, g, b))
155
-
156
- # Avatar circle (left side)
157
- avatar_x = 80
158
- avatar_y = UI["header_height"] // 2
159
- avatar_r = UI["avatar_size"] // 2
 
 
 
 
160
 
161
- # White circle border
162
- draw.ellipse(
163
- [avatar_x - avatar_r - 4, avatar_y - avatar_r - 4,
164
- avatar_x + avatar_r + 4, avatar_y + avatar_r + 4],
165
- fill=(255, 255, 255)
 
 
 
 
 
 
 
166
  )
167
 
168
- # Avatar inner circle (random color)
169
- avatar_color = (random.randint(100, 200), random.randint(80, 150), random.randint(80, 150))
 
 
 
 
170
  draw.ellipse(
171
  [avatar_x - avatar_r, avatar_y - avatar_r,
172
  avatar_x + avatar_r, avatar_y + avatar_r],
173
- fill=avatar_color
174
  )
175
 
176
  # Avatar letter
177
  letter = self.person_b_avatar[:1].upper()
178
- bbox = self.font_name.getbbox(letter)
179
  text_w = bbox[2] - bbox[0]
180
  text_h = bbox[3] - bbox[1]
181
  draw.text(
182
- (avatar_x - text_w // 2, avatar_y - text_h // 2 - 5),
183
  letter,
184
  fill=COLORS["text_white"],
185
- font=self.font_name
186
  )
187
 
188
- # Name (to the right of avatar)
189
- name_x = avatar_x + avatar_r + 30
190
- name_y = avatar_y - 25
191
  draw.text(
192
- (name_x, name_y),
193
  self.person_b_name,
194
  fill=COLORS["text_white"],
195
- font=self.font_name
196
  )
197
 
198
- # "Online" status (below name)
199
- online_y = name_y + 50
200
- draw.text(
201
- (name_x, online_y),
202
- "Online",
203
- fill=COLORS["text_online"],
204
- font=self.font_small
205
- )
206
 
207
- # Three dots menu (right side)
208
- dots_x = CANVAS_WIDTH - 60
209
- dots_y = avatar_y
210
- for i in range(3):
211
- y = dots_y - 25 + i * 25
212
- draw.ellipse(
213
- [dots_x - 5, y - 5, dots_x + 5, y + 5],
214
- fill=COLORS["text_white"]
215
- )
216
 
217
  def _draw_bubble(self, draw: ImageDraw.Draw,
218
  x: int, y: int,
219
  width: int, height: int,
220
  lines: List[str],
221
- timestamp: str,
222
  is_user: bool) -> int:
223
- """
224
- Draw a chat bubble with timestamp.
225
-
226
- Returns:
227
- Bottom Y position of bubble
228
- """
229
- # Bubble color
230
  color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"]
231
- text_color = COLORS["text_dark"] # Dark text on light bubbles
232
-
233
- # Draw rounded rectangle with shadow
234
- shadow_offset = 3
235
- draw.rounded_rectangle(
236
- [x + shadow_offset, y + shadow_offset, x + width + shadow_offset, y + height + shadow_offset],
237
- radius=UI["bubble_radius"],
238
- fill=(200, 200, 200, 100) # Light shadow
239
- )
240
 
 
241
  draw.rounded_rectangle(
242
  [x, y, x + width, y + height],
243
  radius=UI["bubble_radius"],
@@ -247,19 +237,12 @@ class ChatRenderer:
247
  # Draw text
248
  text_x = x + UI["bubble_padding_h"]
249
  text_y = y + UI["bubble_padding_v"]
250
- line_height = self.font.getbbox("Ay")[3] + 6
251
 
252
  for line in lines:
253
- draw.text((text_x, text_y), line, fill=text_color, font=self.font)
254
  text_y += line_height
255
 
256
- # Draw timestamp (bottom right of bubble)
257
- ts_bbox = self.font_timestamp.getbbox(timestamp)
258
- ts_w = ts_bbox[2] - ts_bbox[0]
259
- ts_x = x + width - ts_w - 15
260
- ts_y = y + height - 30
261
- draw.text((ts_x, ts_y), timestamp, fill=COLORS["text_gray"], font=self.font_timestamp)
262
-
263
  return y + height
264
 
265
  def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image:
@@ -273,84 +256,75 @@ class ChatRenderer:
273
  Returns:
274
  PIL Image of the frame
275
  """
276
- # Create transparent image (gameplay will be behind)
277
  img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT), (0, 0, 0, 0))
278
  draw = ImageDraw.Draw(img)
279
 
280
- # Calculate total height needed for messages
281
  message_heights = []
282
- for msg in messages:
 
 
283
  _, height, _ = self._calculate_bubble_size(msg["text"])
284
  message_heights.append(height + UI["bubble_gap"])
285
 
286
  total_msg_height = sum(message_heights)
287
 
288
- # Calculate UI box height (dynamic)
289
- ui_height = UI["chat_area_top"] + total_msg_height + 30
290
-
291
- # Limit maximum height
292
- max_ui_height = 1400
293
- ui_height = min(ui_height, max_ui_height)
294
-
295
- # Draw light gray chat background
296
- draw.rectangle(
297
- [0, UI["header_height"], CANVAS_WIDTH, ui_height],
298
- fill=COLORS["chat_bg"]
299
- )
300
 
301
- # Draw gradient header (fixed on top)
302
- self._draw_gradient_header(img, draw)
303
 
304
- # Draw messages starting below header
305
- current_y = UI["chat_area_top"]
306
 
307
- # Only show last N messages if too many
308
- visible_messages = messages[-UI["max_visible_messages"]:]
309
- start_index = len(messages) - len(visible_messages)
 
310
 
311
- for i, msg in enumerate(visible_messages):
312
  width, height, lines = self._calculate_bubble_size(msg["text"])
313
- timestamp = self._get_timestamp(start_index + i)
314
 
315
  # Position: A (user) = right, B (other) = left
316
  if msg["sender"] == "A":
317
- x = CANVAS_WIDTH - UI["margin_side"] - width
318
  else:
319
- x = UI["margin_side"]
320
 
321
- current_y = self._draw_bubble(draw, x, current_y, width, height, lines, timestamp, msg["sender"] == "A")
322
  current_y += UI["bubble_gap"]
323
 
324
  # Draw typing indicator if needed
325
  if show_typing:
326
- typing_y = current_y + 5
327
- self._draw_typing_indicator(draw, typing_y)
328
 
329
  return img
330
 
331
  def _draw_typing_indicator(self, draw: ImageDraw.Draw, y: int):
332
  """Draw typing indicator (●●●)."""
333
- x = UI["margin_side"]
334
 
335
- # Background bubble (cream color like other messages)
336
- bubble_width = 80
337
- bubble_height = 45
338
  draw.rounded_rectangle(
339
  [x, y, x + bubble_width, y + bubble_height],
340
- radius=18,
341
  fill=COLORS["bubble_other"]
342
  )
343
 
344
  # Three dots
345
  dot_y = y + bubble_height // 2
346
- for i, dx in enumerate([22, 40, 58]):
347
  draw.ellipse(
348
- [x + dx - 5, dot_y - 5, x + dx + 5, dot_y + 5],
349
  fill=COLORS["text_gray"]
350
  )
351
 
352
  def get_ui_height(self, messages: List[dict]) -> int:
353
- """Calculate the height of the chat UI for given messages."""
354
  message_heights = []
355
  visible_messages = messages[-UI["max_visible_messages"]:]
356
 
@@ -358,4 +332,5 @@ class ChatRenderer:
358
  _, height, _ = self._calculate_bubble_size(msg["text"])
359
  message_heights.append(height + UI["bubble_gap"])
360
 
361
- return min(UI["chat_area_top"] + sum(message_heights) + 30, 1400)
 
 
1
  """
2
  Chat UI Renderer for Text Story module.
3
+ Creates iMessage-style floating chat card.
4
  """
5
 
6
  import os
 
15
  CANVAS_WIDTH = 1080
16
  CANVAS_HEIGHT = 1920
17
 
18
+ # ============================================
19
+ # POSITIONING (Mapped from reference image)
20
+ # ============================================
21
+ # Reference shows floating card with:
22
+ # - Top margin: ~8% of height = 150px
23
+ # - Side margins: ~3% = 35px each
24
+ # - Chat box width: 94% of screen = 1010px
25
+ # - Chat box rounded corners
26
+
27
+ LAYOUT = {
28
+ "top_margin": 150, # Empty space at top
29
+ "side_margin": 35, # Side margins left/right
30
+ "chat_box_width": 1010, # Width of chat card (1080 - 35*2)
31
+ "chat_box_radius": 30, # Rounded corners
32
+ "header_height": 130, # Header with avatar + name
33
+ "max_chat_height": 1100, # Maximum height of entire chat box
34
+ }
35
+
36
+ # Colors (iMessage dark style from reference)
37
  COLORS = {
38
+ # Header (dark)
39
+ "header_bg": (28, 28, 30), # #1C1C1E - Dark header
 
40
 
41
+ # Bubbles (exactly like reference)
42
+ "bubble_other": (58, 58, 60), # #3A3A3C - Gray for left (other person)
43
+ "bubble_user": (0, 122, 255), # #007AFF - Blue for right (user)
44
 
45
  # Text
46
+ "text_white": (255, 255, 255), # White text
47
+ "text_gray": (142, 142, 147), # #8E8E93 - Secondary text
48
+ "text_blue": (0, 122, 255), # Blue accent
 
49
 
50
  # Background
51
+ "chat_bg": (0, 0, 0), # Black chat area background
52
  }
53
 
54
  # UI Measurements
55
  UI = {
56
+ "bubble_max_width_ratio": 0.75, # 75% of chat box width
57
+ "bubble_padding_h": 16,
58
+ "bubble_padding_v": 10,
59
+ "bubble_radius": 18,
60
+ "bubble_gap": 8,
61
+ "font_size": 32,
62
+ "header_font_size": 28,
63
+ "small_font_size": 22,
64
+ "avatar_size": 50,
65
+ "max_visible_messages": 8,
 
 
 
66
  }
67
 
68
 
69
  class ChatRenderer:
70
  """
71
+ Renders iMessage-style floating chat card.
72
+ Positioned with margins matching reference image.
73
  """
74
 
75
  def __init__(self,
 
82
 
83
  # Load fonts
84
  self.font = self._load_font(UI["font_size"])
85
+ self.font_header = self._load_font(UI["header_font_size"])
86
+ self.font_small = self._load_font(UI["small_font_size"])
87
+ self.font_avatar = self._load_font(24)
 
 
 
 
88
 
89
  def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
90
  """Load font with fallback."""
 
127
 
128
  return lines if lines else [text]
129
 
 
 
 
 
 
 
130
  def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]:
131
  """Calculate bubble size based on text."""
132
+ max_text_width = int(LAYOUT["chat_box_width"] * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2
133
  lines = self._wrap_text(text, max_text_width)
134
 
135
+ line_height = self.font.getbbox("Ay")[3] + 4
136
  text_height = line_height * len(lines)
137
 
138
  max_line_width = 0
 
140
  bbox = self.font.getbbox(line)
141
  max_line_width = max(max_line_width, bbox[2] - bbox[0])
142
 
143
+ bubble_width = max_line_width + UI["bubble_padding_h"] * 2
 
144
  bubble_height = text_height + UI["bubble_padding_v"] * 2
145
 
146
  return bubble_width, bubble_height, lines
147
 
148
+ def _draw_chat_box_background(self, draw: ImageDraw.Draw, height: int):
149
+ """Draw the floating chat card background with rounded corners."""
150
+ x1 = LAYOUT["side_margin"]
151
+ y1 = LAYOUT["top_margin"]
152
+ x2 = x1 + LAYOUT["chat_box_width"]
153
+ y2 = y1 + height
154
+
155
+ # Draw rounded rectangle for entire chat box
156
+ draw.rounded_rectangle(
157
+ [x1, y1, x2, y2],
158
+ radius=LAYOUT["chat_box_radius"],
159
+ fill=COLORS["chat_bg"]
160
+ )
161
+
162
+ def _draw_header(self, draw: ImageDraw.Draw):
163
+ """Draw iMessage-style header."""
164
+ x_offset = LAYOUT["side_margin"]
165
+ y_start = LAYOUT["top_margin"]
166
 
167
+ # Header background (already drawn as part of chat box)
168
+ header_y2 = y_start + LAYOUT["header_height"]
169
+ draw.rectangle(
170
+ [x_offset, y_start, x_offset + LAYOUT["chat_box_width"], header_y2],
171
+ fill=COLORS["header_bg"]
172
+ )
173
+
174
+ # Round top corners
175
+ draw.rounded_rectangle(
176
+ [x_offset, y_start, x_offset + LAYOUT["chat_box_width"], y_start + 60],
177
+ radius=LAYOUT["chat_box_radius"],
178
+ fill=COLORS["header_bg"]
179
  )
180
 
181
+ # Avatar circle (center)
182
+ avatar_x = x_offset + LAYOUT["chat_box_width"] // 2
183
+ avatar_y = y_start + 40
184
+ avatar_r = UI["avatar_size"] // 2
185
+
186
+ # Gray circle
187
  draw.ellipse(
188
  [avatar_x - avatar_r, avatar_y - avatar_r,
189
  avatar_x + avatar_r, avatar_y + avatar_r],
190
+ fill=(100, 100, 105)
191
  )
192
 
193
  # Avatar letter
194
  letter = self.person_b_avatar[:1].upper()
195
+ bbox = self.font_avatar.getbbox(letter)
196
  text_w = bbox[2] - bbox[0]
197
  text_h = bbox[3] - bbox[1]
198
  draw.text(
199
+ (avatar_x - text_w // 2, avatar_y - text_h // 2 - 2),
200
  letter,
201
  fill=COLORS["text_white"],
202
+ font=self.font_avatar
203
  )
204
 
205
+ # Name below avatar
206
+ name_bbox = self.font_header.getbbox(self.person_b_name)
207
+ name_w = name_bbox[2] - name_bbox[0]
208
  draw.text(
209
+ (avatar_x - name_w // 2, avatar_y + avatar_r + 10),
210
  self.person_b_name,
211
  fill=COLORS["text_white"],
212
+ font=self.font_header
213
  )
214
 
215
+ # Back button (left) - "<15"
216
+ draw.text((x_offset + 20, y_start + 30), "β€Ή15", fill=COLORS["text_blue"], font=self.font_header)
 
 
 
 
 
 
217
 
218
+ # Video icon (right)
219
+ video_x = x_offset + LAYOUT["chat_box_width"] - 50
220
+ draw.text((video_x, y_start + 30), "πŸ“Ή", fill=COLORS["text_blue"], font=self.font_small)
 
 
 
 
 
 
221
 
222
  def _draw_bubble(self, draw: ImageDraw.Draw,
223
  x: int, y: int,
224
  width: int, height: int,
225
  lines: List[str],
 
226
  is_user: bool) -> int:
227
+ """Draw a chat bubble. Returns bottom Y position."""
 
 
 
 
 
 
228
  color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"]
 
 
 
 
 
 
 
 
 
229
 
230
+ # Draw rounded rectangle
231
  draw.rounded_rectangle(
232
  [x, y, x + width, y + height],
233
  radius=UI["bubble_radius"],
 
237
  # Draw text
238
  text_x = x + UI["bubble_padding_h"]
239
  text_y = y + UI["bubble_padding_v"]
240
+ line_height = self.font.getbbox("Ay")[3] + 4
241
 
242
  for line in lines:
243
+ draw.text((text_x, text_y), line, fill=COLORS["text_white"], font=self.font)
244
  text_y += line_height
245
 
 
 
 
 
 
 
 
246
  return y + height
247
 
248
  def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image:
 
256
  Returns:
257
  PIL Image of the frame
258
  """
259
+ # Create transparent image
260
  img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT), (0, 0, 0, 0))
261
  draw = ImageDraw.Draw(img)
262
 
263
+ # Calculate message heights
264
  message_heights = []
265
+ visible_messages = messages[-UI["max_visible_messages"]:]
266
+
267
+ for msg in visible_messages:
268
  _, height, _ = self._calculate_bubble_size(msg["text"])
269
  message_heights.append(height + UI["bubble_gap"])
270
 
271
  total_msg_height = sum(message_heights)
272
 
273
+ # Calculate total chat box height
274
+ chat_box_height = LAYOUT["header_height"] + total_msg_height + 30 # 30px bottom padding
275
+ chat_box_height = min(chat_box_height, LAYOUT["max_chat_height"])
 
 
 
 
 
 
 
 
 
276
 
277
+ # Draw chat box background
278
+ self._draw_chat_box_background(draw, chat_box_height)
279
 
280
+ # Draw header
281
+ self._draw_header(draw)
282
 
283
+ # Draw messages
284
+ x_base = LAYOUT["side_margin"]
285
+ current_y = LAYOUT["top_margin"] + LAYOUT["header_height"] + 10
286
+ bubble_area_width = LAYOUT["chat_box_width"]
287
 
288
+ for msg in visible_messages:
289
  width, height, lines = self._calculate_bubble_size(msg["text"])
 
290
 
291
  # Position: A (user) = right, B (other) = left
292
  if msg["sender"] == "A":
293
+ x = x_base + bubble_area_width - width - 15 # Right aligned with padding
294
  else:
295
+ x = x_base + 15 # Left aligned with padding
296
 
297
+ current_y = self._draw_bubble(draw, x, current_y, width, height, lines, msg["sender"] == "A")
298
  current_y += UI["bubble_gap"]
299
 
300
  # Draw typing indicator if needed
301
  if show_typing:
302
+ self._draw_typing_indicator(draw, current_y)
 
303
 
304
  return img
305
 
306
  def _draw_typing_indicator(self, draw: ImageDraw.Draw, y: int):
307
  """Draw typing indicator (●●●)."""
308
+ x = LAYOUT["side_margin"] + 15
309
 
310
+ bubble_width = 70
311
+ bubble_height = 35
 
312
  draw.rounded_rectangle(
313
  [x, y, x + bubble_width, y + bubble_height],
314
+ radius=14,
315
  fill=COLORS["bubble_other"]
316
  )
317
 
318
  # Three dots
319
  dot_y = y + bubble_height // 2
320
+ for i, dx in enumerate([18, 35, 52]):
321
  draw.ellipse(
322
+ [x + dx - 4, dot_y - 4, x + dx + 4, dot_y + 4],
323
  fill=COLORS["text_gray"]
324
  )
325
 
326
  def get_ui_height(self, messages: List[dict]) -> int:
327
+ """Calculate the height of the chat UI."""
328
  message_heights = []
329
  visible_messages = messages[-UI["max_visible_messages"]:]
330
 
 
332
  _, height, _ = self._calculate_bubble_size(msg["text"])
333
  message_heights.append(height + UI["bubble_gap"])
334
 
335
+ total = LAYOUT["header_height"] + sum(message_heights) + 30
336
+ return min(LAYOUT["top_margin"] + total, LAYOUT["top_margin"] + LAYOUT["max_chat_height"])
requirements.txt CHANGED
@@ -29,3 +29,6 @@ imageio-ffmpeg>=0.4.9
29
  # Trends Analysis
30
  pytrends
31
  pandas
 
 
 
 
29
  # Trends Analysis
30
  pytrends
31
  pandas
32
+
33
+ # YouTube Downloads
34
+ yt-dlp
static/index.html CHANGED
@@ -282,6 +282,9 @@
282
  <button class="tab-btn" data-tab="textstory">
283
  πŸ“± Text Story
284
  </button>
 
 
 
285
  </div>
286
 
287
  <!-- Story Reels Tab -->
@@ -784,6 +787,78 @@
784
  </div>
785
  </div>
786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  <script>
788
  // Tab switching
789
  document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -1478,6 +1553,126 @@
1478
  };
1479
  poll();
1480
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1481
  </script>
1482
 
1483
  <!-- Chat Widget Button -->
 
282
  <button class="tab-btn" data-tab="textstory">
283
  πŸ“± Text Story
284
  </button>
285
+ <button class="tab-btn" data-tab="media">
286
+ πŸ“ Media Uploader
287
+ </button>
288
  </div>
289
 
290
  <!-- Story Reels Tab -->
 
787
  </div>
788
  </div>
789
 
790
+ <!-- Media Uploader Tab -->
791
+ <div id="media-tab" class="tab-content">
792
+ <div class="card">
793
+ <h2>πŸ“ YouTube to HF Uploader</h2>
794
+ <p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
795
+ Download YouTube videos/playlists and upload directly to HuggingFace Dataset
796
+ </p>
797
+
798
+ <form id="mediaUploaderForm">
799
+ <!-- URL Input -->
800
+ <div class="form-group">
801
+ <label>YouTube URL (Video or Playlist) *</label>
802
+ <input type="text" id="mediaUrl"
803
+ placeholder="https://youtube.com/watch?v=... or https://youtube.com/playlist?list=..." required>
804
+ </div>
805
+
806
+ <!-- Folder Selection -->
807
+ <div class="form-row">
808
+ <div class="form-group">
809
+ <label>Target Folder *</label>
810
+ <select id="mediaFolder" required>
811
+ <option value="">Loading folders...</option>
812
+ </select>
813
+ </div>
814
+ <div class="form-group" style="flex: 0 0 auto;">
815
+ <label>&nbsp;</label>
816
+ <button type="button" id="createFolderBtn" class="btn btn-secondary">
817
+ βž• New Folder
818
+ </button>
819
+ </div>
820
+ </div>
821
+
822
+ <!-- Format Toggle -->
823
+ <div class="form-group">
824
+ <label>Download Format *</label>
825
+ <div style="display: flex; gap: 1rem;">
826
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
827
+ <input type="radio" name="mediaFormat" value="mp4" checked>
828
+ 🎬 Video (MP4)
829
+ </label>
830
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
831
+ <input type="radio" name="mediaFormat" value="mp3">
832
+ 🎡 Audio (MP3)
833
+ </label>
834
+ </div>
835
+ </div>
836
+
837
+ <!-- Category (for Audio) -->
838
+ <div id="audioCategorySection" class="form-group" style="display: none;">
839
+ <label>Music Category / Name</label>
840
+ <input type="text" id="audioCategory" placeholder="e.g., emotional, energetic, lofi, sad, happy"
841
+ style="max-width: 300px;">
842
+ <small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
843
+ Files will be saved as: {category}_001.mp3, {category}_002.mp3, etc.
844
+ </small>
845
+ </div>
846
+
847
+ <button type="submit" class="btn btn-primary" style="width: 100%;">
848
+ πŸ“€ Download & Upload to HF
849
+ </button>
850
+ </form>
851
+
852
+ <div id="mediaUploaderStatus" class="status hidden"></div>
853
+
854
+ <!-- Results -->
855
+ <div id="mediaResults" style="display: none; margin-top: 1.5rem;">
856
+ <h3>βœ… Uploaded Files</h3>
857
+ <div id="mediaResultsList" style="max-height: 300px; overflow-y: auto;"></div>
858
+ </div>
859
+ </div>
860
+ </div>
861
+
862
  <script>
863
  // Tab switching
864
  document.querySelectorAll('.tab-btn').forEach(btn => {
 
1553
  };
1554
  poll();
1555
  }
1556
+
1557
+ // ============================================
1558
+ // MEDIA UPLOADER
1559
+ // ============================================
1560
+
1561
+ // Load HF folders on page load
1562
+ async function loadHFFolders() {
1563
+ const select = document.getElementById('mediaFolder');
1564
+ try {
1565
+ const res = await fetch('/api/utils/hf-folders');
1566
+ if (res.ok) {
1567
+ const data = await res.json();
1568
+ select.innerHTML = '<option value="">Select folder...</option>';
1569
+ data.folders.forEach(folder => {
1570
+ select.innerHTML += `<option value="${folder}">${folder}</option>`;
1571
+ });
1572
+ } else {
1573
+ select.innerHTML = '<option value="">Failed to load folders</option>';
1574
+ }
1575
+ } catch (err) {
1576
+ select.innerHTML = '<option value="">Error loading folders</option>';
1577
+ }
1578
+ }
1579
+
1580
+ // Toggle category input based on format
1581
+ document.querySelectorAll('input[name="mediaFormat"]').forEach(radio => {
1582
+ radio.addEventListener('change', (e) => {
1583
+ const categorySection = document.getElementById('audioCategorySection');
1584
+ categorySection.style.display = e.target.value === 'mp3' ? 'block' : 'none';
1585
+ });
1586
+ });
1587
+
1588
+ // Create new folder
1589
+ document.getElementById('createFolderBtn').addEventListener('click', async () => {
1590
+ const name = prompt('Enter new folder name:');
1591
+ if (!name) return;
1592
+
1593
+ try {
1594
+ const res = await fetch('/api/utils/hf-folders', {
1595
+ method: 'POST',
1596
+ headers: { 'Content-Type': 'application/json' },
1597
+ body: JSON.stringify({ name: name.trim() })
1598
+ });
1599
+
1600
+ if (res.ok) {
1601
+ alert('βœ… Folder created: ' + name);
1602
+ loadHFFolders();
1603
+ } else {
1604
+ const err = await res.json();
1605
+ alert('❌ Failed: ' + (err.detail || 'Unknown error'));
1606
+ }
1607
+ } catch (err) {
1608
+ alert('❌ Error: ' + err.message);
1609
+ }
1610
+ });
1611
+
1612
+ // Media Uploader form submission
1613
+ document.getElementById('mediaUploaderForm').addEventListener('submit', async (e) => {
1614
+ e.preventDefault();
1615
+
1616
+ const status = document.getElementById('mediaUploaderStatus');
1617
+ const resultsDiv = document.getElementById('mediaResults');
1618
+ const resultsList = document.getElementById('mediaResultsList');
1619
+
1620
+ status.className = 'status processing';
1621
+ status.innerHTML = '⏳ Downloading and uploading... This may take a while...';
1622
+ status.classList.remove('hidden');
1623
+ resultsDiv.style.display = 'none';
1624
+
1625
+ const format = document.querySelector('input[name="mediaFormat"]:checked').value;
1626
+ const data = {
1627
+ url: document.getElementById('mediaUrl').value,
1628
+ folder: document.getElementById('mediaFolder').value,
1629
+ format: format
1630
+ };
1631
+
1632
+ if (format === 'mp3') {
1633
+ data.category = document.getElementById('audioCategory').value || 'music';
1634
+ }
1635
+
1636
+ try {
1637
+ const res = await fetch('/api/utils/youtube-upload', {
1638
+ method: 'POST',
1639
+ headers: { 'Content-Type': 'application/json' },
1640
+ body: JSON.stringify(data)
1641
+ });
1642
+
1643
+ const result = await res.json();
1644
+
1645
+ if (res.ok && result.uploaded && result.uploaded.length > 0) {
1646
+ status.className = 'status success';
1647
+ status.innerHTML = `βœ… Uploaded ${result.uploaded.length} file(s)!`;
1648
+
1649
+ // Show results
1650
+ resultsList.innerHTML = '';
1651
+ result.uploaded.forEach(file => {
1652
+ resultsList.innerHTML += `
1653
+ <div style="padding: 0.5rem; background: var(--bg-secondary); border-radius: 8px; margin-bottom: 0.5rem;">
1654
+ <strong>${file.filename || file.id}</strong><br>
1655
+ <small>${file.title}</small><br>
1656
+ <a href="${file.url}" target="_blank" style="color: var(--accent);">πŸ”— View on HF</a>
1657
+ </div>
1658
+ `;
1659
+ });
1660
+ resultsDiv.style.display = 'block';
1661
+
1662
+ // Refresh folders if new ones created
1663
+ loadHFFolders();
1664
+ } else {
1665
+ status.className = 'status error';
1666
+ status.innerHTML = '❌ ' + (result.error || result.detail || 'Upload failed');
1667
+ }
1668
+ } catch (err) {
1669
+ status.className = 'status error';
1670
+ status.innerHTML = '❌ Error: ' + err.message;
1671
+ }
1672
+ });
1673
+
1674
+ // Load folders on page load
1675
+ loadHFFolders();
1676
  </script>
1677
 
1678
  <!-- Chat Widget Button -->