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 +20 -0
- modules/shared/router.py +117 -0
- modules/shared/services/youtube_uploader.py +340 -0
- modules/text_story/router.py +6 -2
- modules/text_story/services/renderer.py +131 -156
- requirements.txt +3 -0
- static/index.html +195 -0
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
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
|
@@ -15,48 +15,61 @@ logger = logging.getLogger(__name__)
|
|
| 15 |
CANVAS_WIDTH = 1080
|
| 16 |
CANVAS_HEIGHT = 1920
|
| 17 |
|
| 18 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
COLORS = {
|
| 20 |
-
# Header
|
| 21 |
-
"
|
| 22 |
-
"header_end": (75, 0, 130), # Indigo
|
| 23 |
|
| 24 |
-
# Bubbles
|
| 25 |
-
"bubble_other": (
|
| 26 |
-
"bubble_user": (
|
| 27 |
|
| 28 |
# Text
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"text_online": (50, 205, 50), # Green for "Online"
|
| 33 |
|
| 34 |
# Background
|
| 35 |
-
"chat_bg": (
|
| 36 |
}
|
| 37 |
|
| 38 |
# UI Measurements
|
| 39 |
UI = {
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 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
|
| 59 |
-
|
| 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.
|
| 73 |
-
self.
|
| 74 |
-
self.
|
| 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(
|
| 130 |
lines = self._wrap_text(text, max_text_width)
|
| 131 |
|
| 132 |
-
line_height = self.font.getbbox("Ay")[3] +
|
| 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 |
-
|
| 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
|
| 147 |
-
"""Draw
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
#
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
fill=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
)
|
| 167 |
|
| 168 |
-
# Avatar
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
draw.ellipse(
|
| 171 |
[avatar_x - avatar_r, avatar_y - avatar_r,
|
| 172 |
avatar_x + avatar_r, avatar_y + avatar_r],
|
| 173 |
-
fill=
|
| 174 |
)
|
| 175 |
|
| 176 |
# Avatar letter
|
| 177 |
letter = self.person_b_avatar[:1].upper()
|
| 178 |
-
bbox = self.
|
| 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 -
|
| 183 |
letter,
|
| 184 |
fill=COLORS["text_white"],
|
| 185 |
-
font=self.
|
| 186 |
)
|
| 187 |
|
| 188 |
-
# Name
|
| 189 |
-
|
| 190 |
-
|
| 191 |
draw.text(
|
| 192 |
-
(
|
| 193 |
self.person_b_name,
|
| 194 |
fill=COLORS["text_white"],
|
| 195 |
-
font=self.
|
| 196 |
)
|
| 197 |
|
| 198 |
-
#
|
| 199 |
-
|
| 200 |
-
draw.text(
|
| 201 |
-
(name_x, online_y),
|
| 202 |
-
"Online",
|
| 203 |
-
fill=COLORS["text_online"],
|
| 204 |
-
font=self.font_small
|
| 205 |
-
)
|
| 206 |
|
| 207 |
-
#
|
| 208 |
-
|
| 209 |
-
|
| 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] +
|
| 251 |
|
| 252 |
for line in lines:
|
| 253 |
-
draw.text((text_x, text_y), line, fill=
|
| 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
|
| 277 |
img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT), (0, 0, 0, 0))
|
| 278 |
draw = ImageDraw.Draw(img)
|
| 279 |
|
| 280 |
-
# Calculate
|
| 281 |
message_heights = []
|
| 282 |
-
|
|
|
|
|
|
|
| 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
|
| 289 |
-
|
| 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
|
| 302 |
-
self.
|
| 303 |
|
| 304 |
-
# Draw
|
| 305 |
-
|
| 306 |
|
| 307 |
-
#
|
| 308 |
-
|
| 309 |
-
|
|
|
|
| 310 |
|
| 311 |
-
for
|
| 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 =
|
| 318 |
else:
|
| 319 |
-
x =
|
| 320 |
|
| 321 |
-
current_y = self._draw_bubble(draw, x, current_y, width, height, lines,
|
| 322 |
current_y += UI["bubble_gap"]
|
| 323 |
|
| 324 |
# Draw typing indicator if needed
|
| 325 |
if show_typing:
|
| 326 |
-
|
| 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 =
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
bubble_height = 45
|
| 338 |
draw.rounded_rectangle(
|
| 339 |
[x, y, x + bubble_width, y + bubble_height],
|
| 340 |
-
radius=
|
| 341 |
fill=COLORS["bubble_other"]
|
| 342 |
)
|
| 343 |
|
| 344 |
# Three dots
|
| 345 |
dot_y = y + bubble_height // 2
|
| 346 |
-
for i, dx in enumerate([
|
| 347 |
draw.ellipse(
|
| 348 |
-
[x + dx -
|
| 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
|
| 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 |
-
|
|
|
|
|
|
| 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> </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 -->
|