diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eed4fa1..3c38add 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ mod media; mod models; mod transcription; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use tauri::{Emitter, Manager}; @@ -515,9 +515,13 @@ fn render_flat_clip_for_candidate( let output_path = documents_project_dir(&project)? .join("clips") .join(format!("clip-{:02}_flat.mp4", candidate.rank)); + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create output dir: {}", e))?; + } let mut srt_path = None; - let mut drawtext_filters = None; + let mut video_filter = None; let probe = media::probe_media(&project.source_path).ok(); let cropped_width = if let Some(p) = &probe { @@ -532,20 +536,18 @@ fn render_flat_clip_for_candidate( if let Ok(Some(transcript_record)) = state.db.latest_transcript(&project.id) { if let Ok(normalized) = serde_json::from_str::(&transcript_record.raw_json) { let srt_content = generate_srt(&normalized.words, candidate.start_sec, candidate.end_sec); - let clip_srt_path = project_dir(&state, &project.id).join(format!("clip-{}.srt", candidate.id)); + #[cfg(target_os = "windows")] + let subtitles_dir = PathBuf::from(r"D:\AS\raw\subtitles"); + #[cfg(not(target_os = "windows"))] + let subtitles_dir = project_dir(&state, &project.id); + std::fs::create_dir_all(&subtitles_dir) + .map_err(|e| format!("Failed to create subtitles dir: {}", e))?; + let clip_srt_path = subtitles_dir.join(format!("clip-{}.srt", candidate.id)); if std::fs::write(&clip_srt_path, srt_content).is_ok() { srt_path = Some(clip_srt_path); } - let style = project.caption_style.as_deref().unwrap_or("modern-box"); - let drawtext = build_drawtext_filters( - &normalized.words, - candidate.start_sec, - candidate.end_sec, - cropped_width, - style, - ); - if !drawtext.is_empty() { - drawtext_filters = Some(drawtext); + if let Some(ref subtitle_path) = srt_path { + video_filter = Some(build_subtitles_filter(subtitle_path)); } } } @@ -555,7 +557,7 @@ fn render_flat_clip_for_candidate( candidate.start_sec, candidate.end_sec, &output_path, - drawtext_filters.as_deref(), + video_filter.as_deref(), ) { Ok(path) => { let path_string = path.to_string_lossy().to_string(); @@ -628,6 +630,158 @@ fn rename_project( state.db.rename_project(&project_id, &name).map_err(to_command_error) } +#[derive(Clone, serde::Serialize)] +struct DownloadProgressPayload { + status: String, + percentage: f64, +} + +fn extract_youtube_id(url: &str) -> Option { + if let Some(pos) = url.find("v=") { + let after_v = &url[pos + 2..]; + let end = after_v.find('&').unwrap_or(after_v.len()); + return Some(after_v[..end].to_string()); + } + if let Some(pos) = url.find("youtu.be/") { + let after_slash = &url[pos + 9..]; + let end = after_slash.find('?').unwrap_or(after_slash.len()); + return Some(after_slash[..end].to_string()); + } + if let Some(pos) = url.find("youtube.com/shorts/") { + let after_shorts = &url[pos + 19..]; + let end = after_shorts.find('?').unwrap_or(after_shorts.len()); + return Some(after_shorts[..end].to_string()); + } + None +} + +#[tauri::command] +async fn download_youtube_video( + app: tauri::AppHandle, + url: String, + resolution: String, +) -> Result { + #[cfg(target_os = "windows")] + let base = PathBuf::from("D:\\AS\\raw\\youtube_downloads"); + + #[cfg(not(target_os = "windows"))] + let base = { + let documents_dir = dirs::document_dir() + .ok_or_else(|| "Could not find your Documents folder for download output.".to_string())?; + documents_dir.join("AutoShorts").join("youtube_downloads") + }; + + std::fs::create_dir_all(&base).map_err(|e| format!("Failed to create download directory: {}", e))?; + + let format_selector = match resolution.as_str() { + "480p" => "bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=480]+bestaudio/best", + "720p" => "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=720]+bestaudio/best", + "1080p" => "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=1080]+bestaudio/best", + "2160p" => "bestvideo[height<=2160][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best", + _ => "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best", + }; + + let fallback_id = format!( + "ts-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let final_id = extract_youtube_id(&url).unwrap_or(fallback_id); + let output_template = base.join(format!("yt-{}.%(ext)s", final_id)); + let expected_file = base.join(format!("yt-{}.mp4", final_id)); + + if expected_file.exists() { + let _ = app.emit( + "youtube-download-progress", + DownloadProgressPayload { + status: "Already downloaded. Loading...".to_string(), + percentage: 100.0, + }, + ); + return Ok(expected_file.to_string_lossy().to_string()); + } + + let mut cmd = std::process::Command::new("yt-dlp"); + cmd.args([ + "-f", + format_selector, + "--merge-output-format", + "mp4", + "--output", + &output_template.to_string_lossy(), + "--newline", + &url, + ]); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| { + format!( + "Failed to start yt-dlp. Please check that yt-dlp is installed and in your PATH. Error: {}", + e + ) + })?; + + let stdout = child.stdout.take().ok_or("Failed to capture yt-dlp stdout")?; + let reader = std::io::BufReader::new(stdout); + + use std::io::BufRead; + for line_result in reader.lines() { + if let Ok(line) = line_result { + if line.contains("[download]") { + if let Some(pct_pos) = line.find('%') { + let text_before = &line[..pct_pos]; + if let Some(last_space) = text_before.rfind(' ') { + let pct_str = &text_before[last_space + 1..]; + if let Ok(pct) = pct_str.trim().parse::() { + let speed_eta = line[pct_pos + 1..].trim().to_string(); + let _ = app.emit( + "youtube-download-progress", + DownloadProgressPayload { + status: format!("Downloading: {}% ({})", pct, speed_eta), + percentage: pct, + }, + ); + } + } + } + } + } + } + + let status = child.wait().map_err(|e| format!("Failed to wait for yt-dlp: {}", e))?; + if !status.success() { + let mut err_str = String::new(); + if let Some(mut stderr) = child.stderr.take() { + use std::io::Read; + let _ = stderr.read_to_string(&mut err_str); + } + let clean_err = err_str.trim(); + if clean_err.is_empty() { + return Err("yt-dlp exited with an error. Please verify the URL and your internet connection.".to_string()); + } else { + return Err(format!("yt-dlp error: {}", clean_err)); + } + } + + if expected_file.exists() { + Ok(expected_file.to_string_lossy().to_string()) + } else { + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(&format!("yt-{}", final_id)) { + return Ok(entry.path().to_string_lossy().to_string()); + } + } + } + Err("Download completed but the expected output file could not be found.".to_string()) + } +} + pub fn run() { let _ = dotenvy::dotenv(); @@ -659,7 +813,8 @@ pub fn run() { set_selected_clip_count, render_flat_clip_for_candidate, delete_project, - rename_project + rename_project, + download_youtube_video ]) .run(tauri::generate_context!()) .expect("error while running AutoShorts"); @@ -670,11 +825,21 @@ fn project_dir(state: &AppState, project_id: &str) -> PathBuf { } fn documents_project_dir(project: &Project) -> Result { - let documents_dir = dirs::document_dir() - .ok_or_else(|| "Could not find your Documents folder for clip output.".to_string())?; - Ok(documents_dir - .join("AutoShorts") - .join(project_output_slug(project))) + #[cfg(target_os = "windows")] + let base = PathBuf::from("D:\\AS\\raw"); + + #[cfg(not(target_os = "windows"))] + let base = { + let documents_dir = dirs::document_dir() + .ok_or_else(|| "Could not find your Documents folder for clip output.".to_string())?; + documents_dir.join("AutoShorts").join(project_output_slug(project)) + }; + + #[cfg(target_os = "windows")] + let _ = project; // suppress unused warning on Windows + + std::fs::create_dir_all(&base).map_err(|e| format!("Failed to create output dir: {}", e))?; + Ok(base) } fn project_output_slug(project: &Project) -> String { @@ -812,6 +977,16 @@ fn format_srt_time(start: f64, end: f64) -> String { format!("{} --> {}", format_time(start), format_time(end)) } +fn build_subtitles_filter(subtitle_path: &Path) -> String { + let path = subtitle_path + .to_string_lossy() + .replace('\\', "/"); + let escaped_path = path + .replace(':', "\\\\:") + .replace(' ', "\\\\ "); + format!("subtitles={}:force_style='Alignment=2,MarginV=30'", escaped_path) +} + fn build_drawtext_filters( words: &[TranscriptWord], start_sec: f64, @@ -856,80 +1031,59 @@ fn build_drawtext_filters( let fontsize = ((cropped_width as f64) * 0.075).clamp(16.0, 80.0).round() as i64; let padding = ((fontsize as f64) * 0.3).clamp(4.0, 24.0).round() as i64; - // Premium system font hierarchy - let font_paths = [ - // macOS - "/System/Library/Fonts/Supplemental/Futura.ttc", - "/System/Library/Fonts/Avenir Next.ttc", - "/System/Library/Fonts/Supplemental/Arial Bold.ttf", - "/System/Library/Fonts/Helvetica.ttc", - // Windows - "C:/Windows/Fonts/SegoeUIb.ttf", - "C:/Windows/Fonts/arialbd.ttf", - "C:/Windows/Fonts/arial.ttf", - // Linux - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", - ]; - let mut font_option = String::new(); - for path in &font_paths { - if std::path::Path::new(path).exists() { - font_option = format!("fontfile='{}':", path.replace(':', "\:")); - break; - } - } - + let escaped_text = clean_text.replace('\'', "\\'"); + let drawtext = match caption_style { "classic-outline" => { // Classic yellow text with a bold outline (CapCut style) let borderw = ((fontsize as f64) * 0.1).clamp(2.0, 8.0).round() as i64; format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.65:fontsize={}:fontcolor=yellow:borderw={}:bordercolor=black:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, borderw, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=yellow:borderw={}:bordercolor=black:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, borderw, start_rel, end_rel ) } "minimal-shadow" => { // Sleek white text with a soft drop shadow (Minimalist) format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.7:fontsize={}:fontcolor=white:shadowcolor=black@0.5:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=white:shadowcolor=black@0.5:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, start_rel, end_rel ) } "vibrant-cyan" => { // Modern Avenir Next look with clean cyan color and thin shadow format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.7:fontsize={}:fontcolor=0x00FFFF:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=0x00FFFF:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, start_rel, end_rel ) } "vibrant-yellow-box" => { // Vibrant black text inside a solid yellow background box (Motivational/TikTok style) format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.72:fontsize={}:fontcolor=black:box=1:boxcolor=0xffff00e0:boxborderw={}:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, padding, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=black:box=1:boxcolor=0xffff00e0:boxborderw={}:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, padding, start_rel, end_rel ) } "vibrant-green" => { // High-energy neon green text with outline & drop shadow (Hormozi style) let borderw = ((fontsize as f64) * 0.08).clamp(1.5, 6.0).round() as i64; format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.7:fontsize={}:fontcolor=0x39FF14:borderw={}:bordercolor=black:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, borderw, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=0x39FF14:borderw={}:bordercolor=black:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, borderw, start_rel, end_rel ) } "vibrant-red" => { // Dramatic red text with outline & drop shadow (Gaming/Drama style) let borderw = ((fontsize as f64) * 0.08).clamp(1.5, 6.0).round() as i64; format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.7:fontsize={}:fontcolor=0xFF3B30:borderw={}:bordercolor=black:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, borderw, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=0xFF3B30:borderw={}:bordercolor=black:shadowcolor=black@0.6:shadowx=2:shadowy=2:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, borderw, start_rel, end_rel ) } _ => { // modern-box (Default): white text with clean box background format!( - "drawtext={}text='{}':x=(w-text_w)/2:y=h*0.72:fontsize={}:fontcolor=white:box=1:boxcolor=0x000000b0:boxborderw={}:enable='between(t,{:.3},{:.3})'", - font_option, clean_text, fontsize, padding, start_rel, end_rel + "drawtext=text='{}':x=(w-text_w)/2:y=h-text_h-30:fontsize={}:fontcolor=white:box=1:boxcolor=0x000000b0:boxborderw={}:enable='between(t,{:.3},{:.3})'", + escaped_text, fontsize, padding, start_rel, end_rel ) } }; diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs index 27907a3..3dad913 100644 --- a/src-tauri/src/llm.rs +++ b/src-tauri/src/llm.rs @@ -81,7 +81,7 @@ Avoid rambling setup, context-dependent references, and pure filler. Return up t let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) } else { - 30.0 + 15.0 }; parse_candidate_json(&text, min_duration) } @@ -144,7 +144,7 @@ Avoid rambling setup, context-dependent references, and pure filler. Return up t let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) } else { - 30.0 + 15.0 }; parse_candidate_json(&text, min_duration) } @@ -230,7 +230,7 @@ Ensure the 'start' and 'end' values correspond to actual timestamps in the trans let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) } else { - 30.0 + 15.0 }; parse_candidate_json(&res_body.message.content, min_duration) } @@ -286,14 +286,24 @@ fn compact_segments(segments: &[TranscriptSegment]) -> String { } fn parse_candidate_json(text: &str, min_duration: f64) -> Result> { - let trimmed = text - .trim() - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - - let val: serde_json::Value = serde_json::from_str(trimmed).context("parsing candidate JSON")?; + let mut trimmed = text.trim(); + + let start_idx = trimmed.find(|c| c == '{' || c == '['); + let end_idx = trimmed.rfind(|c| c == '}' || c == ']'); + + if let (Some(s), Some(e)) = (start_idx, end_idx) { + if e >= s { + trimmed = &trimmed[s..=e]; + } + } + + // Fix common LLM hallucination where it forgets the colon + let fixed_json = trimmed + .replace("\"rationale\" \"", "\"rationale\": \"") + .replace("\"rationale \"", "\"rationale\": \"") + .replace("\"rationale\"\"", "\"rationale\": \""); + + let val: serde_json::Value = serde_json::from_str(&fixed_json).context(format!("parsing candidate JSON. Trimmed: {}", fixed_json))?; let candidates_arr = if val.is_array() { val.as_array().cloned() diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 00170ad..8e82ba6 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -106,7 +106,7 @@ pub fn render_flat_clip( start_sec: f64, end_sec: f64, output_path: &Path, - drawtext_filters: Option<&str>, + video_filter: Option<&str>, ) -> Result { if !command_exists("ffmpeg") { return Err(anyhow!("ffmpeg is not installed or not available on PATH")); @@ -123,13 +123,13 @@ pub fn render_flat_clip( let has_video = probe.map(|p| p.has_video).unwrap_or(false); let mut cmd = Command::new("ffmpeg"); - cmd.args(["-y", "-i", source_path, "-ss", &start, "-to", &end]); + cmd.args(["-y", "-ss", &start, "-to", &end, "-i", source_path]); if has_video { let mut filter = "crop=w='2*trunc(min(iw,ih*9/16)/2)':h='2*trunc(min(ih,iw*16/9)/2)'".to_string(); - if let Some(drawtext) = drawtext_filters { - if !drawtext.is_empty() { - filter = format!("{},{}", filter, drawtext); + if let Some(extra_filter) = video_filter { + if !extra_filter.is_empty() { + filter = format!("{},{}", filter, extra_filter); } } cmd.args(["-vf", &filter]); diff --git a/src/main.tsx b/src/main.tsx index 4e25abe..9bccab9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,12 @@ import { Copy, Database, Cloud, + Youtube, + ChevronDown, + ChevronUp, + Hash, + Type, + AlignLeft, } from "lucide-react"; import "./styles.css"; @@ -119,6 +125,13 @@ function App() { const [showStyleModal, setShowStyleModal] = useState(false); const [selectedStyle, setSelectedStyle] = useState("modern-box"); const [mediaPathToImport, setMediaPathToImport] = useState(null); + + const [showYouTubeModal, setShowYouTubeModal] = useState(false); + const [youtubeUrl, setYoutubeUrl] = useState(""); + const [youtubeResolution, setYoutubeResolution] = useState("1080p"); + const [youtubeDownloadProgress, setYoutubeDownloadProgress] = useState(0); + const [youtubeDownloadStatus, setYoutubeDownloadStatus] = useState(""); + const [isDownloadingYouTube, setIsDownloadingYouTube] = useState(false); // Persistence logic from localStorage const [isOnboarded, setIsOnboarded] = useState(null); @@ -308,6 +321,61 @@ function App() { } } + async function handleYouTubeDownloadAndImport() { + if (!youtubeUrl.trim()) return; + const url = youtubeUrl.trim(); + const resolution = youtubeResolution; + const style = selectedStyle; + + setShowYouTubeModal(false); + setYoutubeUrl(""); + setIsDownloadingYouTube(true); + setYoutubeDownloadProgress(0); + setYoutubeDownloadStatus("Starting YouTube download..."); + + let localPath: string | null = null; + try { + const unlisten = await listen<{ + status: string; + percentage: number; + }>("youtube-download-progress", (event) => { + const payload = event.payload; + setYoutubeDownloadStatus(payload.status); + setYoutubeDownloadProgress(Math.round(payload.percentage)); + }); + + const downloadedPath = await invoke("download_youtube_video", { + url, + resolution, + }); + unlisten(); + localPath = downloadedPath; + } catch (err) { + alert("YouTube download failed: " + String(err)); + setIsDownloadingYouTube(false); + return; + } + + setIsDownloadingYouTube(false); + + if (localPath) { + let newProjectId: string | null = null; + await run("import", async () => { + const project = await invoke("create_project_from_path", { + path: localPath, + transcriptionMode: transcriptionEngine === "local" ? "local" : "cloud", + captionStyle: style, + }); + newProjectId = project.id; + await refresh(project.id); + }); + + if (newProjectId) { + await runAutoPipeline(newProjectId); + } + } + } + async function runAutoPipeline(projectId: string) { setError(null); const env = await invoke("environment_status"); @@ -563,6 +631,16 @@ function App() { Import recording + +
+ + )} @@ -1026,26 +1121,153 @@ function App() { )} - {downloadingModelName && ( + {showYouTubeModal && ( +
+
+
+
+ +

Import from YouTube

+
+

Paste a YouTube URL, choose the max resolution, and select caption style to import.

+
+ +
+
+ + setYoutubeUrl(e.target.value)} + /> +
+ +
+ + +
+ +
+ +
+
setSelectedStyle("modern-box")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Modern Box
+
+
setSelectedStyle("classic-outline")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Classic Outline
+
+
setSelectedStyle("minimal-shadow")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Minimal Shadow
+
+
setSelectedStyle("vibrant-cyan")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Vibrant Cyan
+
+
setSelectedStyle("vibrant-yellow-box")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Vibrant Yellow Box
+
+
setSelectedStyle("vibrant-green")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Vibrant Green
+
+
setSelectedStyle("vibrant-red")} + style={{ padding: '8px', cursor: 'pointer' }} + > +
Vibrant Red
+
+
+
+
+ +
+ + +
+
+
+ )} + {isDownloadingYouTube && (
-

Downloading Ollama Model

-

Downloading model weights for "{downloadingModelName}". Please do not close the app.

+

Downloading YouTube Video

+

Downloading video format via yt-dlp. Please do not close the app.

- +
-
+
- {modelDownloadStatus} - {modelDownloadProgress}% + {youtubeDownloadStatus} + {youtubeDownloadProgress}%
@@ -1064,6 +1286,145 @@ function StatusPill({ label, active }: { label: string; active?: boolean }) { ); } +// ─── YouTube Caption Panel ─────────────────────────────────────────────────── + +function generateYouTubeCaption(hook: string, rationale: string, rank: number) { + // Build YouTube title (do not auto-truncate, let user edit) + const title = hook.replace(/["']/g, "").trim(); + + // Hashtags extracted from rationale keywords + generic shorts tags + const rationaleWords = rationale + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 4) + .slice(0, 5) + .map((w) => `#${w}`); + const genericTags = ["#Shorts", "#viral", "#fyp", "#trending", "#reels"]; + const hashtags = [...new Set([...rationaleWords, ...genericTags])].slice(0, 8).join(" "); + + // Description + const description = + `${rationale}\n\n` + + `🔥 Clip #${rank} | AutoShorts\n\n` + + hashtags; + + return { title, description, hashtags }; +} + +type CopyButtonProps = { text: string; label?: string }; +function CopyButton({ text, label }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + void navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +function YouTubeCaptionPanel({ + hook, + rationale, + startSec, + endSec, + rank, +}: { + hook: string; + rationale: string; + startSec: number; + endSec: number; + rank: number; +}) { + const [open, setOpen] = useState(false); + const { title, description, hashtags } = generateYouTubeCaption(hook, rationale, rank); + const duration = Math.round(endSec - startSec); + + return ( +
+ + + {open && ( +
+ {/* Title */} +
+
+ + Title + 90 ? "warn" : ""}`}> + {title.length}/100 + + +
+