diff --git a/src/cli/args.ts b/src/cli/args.ts index 89c8b20..095d399 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -2,14 +2,14 @@ export interface CliArgs { url?: string; output: string; format: string; - segments: number; + peakThreshold: number; } export function parseArgs(): CliArgs { const args: CliArgs = { output: "./downloads", format: "best", - segments: 1, + peakThreshold: 0.3, }; const rawArgs = Bun.argv; @@ -24,11 +24,11 @@ export function parseArgs(): CliArgs { } else if (arg === "-f" || arg === "--format") { args.format = nextArg || "best"; i++; - } else if (arg === "-n" || arg === "--segments") { - args.segments = parseInt(nextArg || "1", 10); + } else if (arg === "-t" || arg === "--threshold") { + args.peakThreshold = parseFloat(nextArg || "0.3"); i++; } else if (arg === "-h" || arg === "--help") { - console.log(`YouTube Most Watched Segments Downloader + console.log(`YouTube Peak Segment Downloader Usage: yt-segments [options] @@ -38,13 +38,14 @@ Arguments: Options: -o, --output Output directory (default: ./downloads) -f, --format Video format (default: best) - -n, --segments Number of top segments to download (default: 1) + -t, --threshold Peak detection threshold 0.1-1.0 (default: 0.3) + Lower = more segments detected as peaks -h, --help Show this help message Examples: yt-segments "https://www.youtube.com/watch?v=abc123" yt-segments "https://youtu.be/abc123" -o ./videos -f mp4 - yt-segments "https://www.youtube.com/watch?v=abc123" -n 3 + yt-segments "https://www.youtube.com/watch?v=abc123" -t 0.5 `); process.exit(0); } else if (!arg.startsWith("-") && !arg.includes("bun")) { diff --git a/src/cli/downloader.ts b/src/cli/downloader.ts index 4fd0a0a..c151561 100644 --- a/src/cli/downloader.ts +++ b/src/cli/downloader.ts @@ -6,6 +6,7 @@ export interface DownloadOptions { url: string; outputDir: string; format: string; + peakThreshold: number; } interface RawHeatmapSegment { @@ -18,6 +19,13 @@ interface RawHeatmapSegment { value?: number; } +interface ProcessedSegment { + start: number; + end: number; + intensity: number; + peakScore: number; +} + interface VideoInfo { title: string; duration: number; @@ -53,17 +61,11 @@ async function getVideoInfo(url: string): Promise { try { const info = JSON.parse(stdout); - const heatmapData = info.heatmap; - - // Debug: log the actual structure if needed - if (heatmapData && heatmapData.length > 0) { - console.log("[Debug] First heatmap entry:", JSON.stringify(heatmapData[0], null, 2)); - } resolve({ title: info.title || "video", duration: info.duration || 0, - heatmap: heatmapData, + heatmap: info.heatmap, }); } catch (parseError) { reject(new Error(`Failed to parse video info: ${parseError}`)); @@ -141,8 +143,61 @@ function getIntensity(segment: RawHeatmapSegment): number { return segment.intensity ?? segment.heat ?? segment.value ?? 0; } +function findPeakSegments( + segments: RawHeatmapSegment[], + threshold: number = 0.3 +): ProcessedSegment[] { + if (segments.length < 3) { + return []; + } + + // Convert to processed format + const processed = segments + .map(seg => ({ + start: getStartTime(seg), + end: getEndTime(seg), + intensity: getIntensity(seg), + peakScore: 0, + })) + .filter(seg => + Number.isFinite(seg.start) && + Number.isFinite(seg.end) && + Number.isFinite(seg.intensity) + ); + + if (processed.length < 3) { + return []; + } + + // Calculate peak score for each segment + // A peak is where intensity is significantly higher than neighbors + const scored = processed.map((seg, i) => { + const prevIntensity = i > 0 ? processed[i - 1].intensity : seg.intensity; + const nextIntensity = i < processed.length - 1 ? processed[i + 1].intensity : seg.intensity; + + // Peak score = how much higher this segment is compared to average of neighbors + const avgNeighborIntensity = (prevIntensity + nextIntensity) / 2; + const peakScore = avgNeighborIntensity > 0 + ? (seg.intensity - avgNeighborIntensity) / avgNeighborIntensity + : 0; + + return { + ...seg, + peakScore, + }; + }); + + // Filter segments that are true peaks (higher than neighbors) + const peaks = scored.filter(seg => seg.peakScore > threshold); + + // Sort by peak score (highest peaks first) + peaks.sort((a, b) => b.peakScore - a.peakScore); + + return peaks; +} + export async function downloadMostWatchedSegment(options: DownloadOptions): Promise { - const { url, outputDir, format } = options; + const { url, outputDir, format, peakThreshold } = options; // Create output directory if it doesn't exist if (!existsSync(outputDir)) { @@ -160,7 +215,6 @@ export async function downloadMostWatchedSegment(options: DownloadOptions): Prom // Check for heatmap data if (!info.heatmap || info.heatmap.length === 0) { console.log("\nNo heatmap data available for this video."); - console.log("The video may not have enough view data to determine most watched segments."); console.log("Downloading full video instead..."); const outputPath = join(outputDir, `${safeTitle}.%(ext)s`); @@ -170,50 +224,49 @@ export async function downloadMostWatchedSegment(options: DownloadOptions): Prom console.log(`\nHeatmap data found: ${info.heatmap.length} segments`); - // Filter out invalid segments and find the most watched - const validSegments = info.heatmap.filter(seg => { - const start = getStartTime(seg); - const end = getEndTime(seg); - const intensity = getIntensity(seg); - return Number.isFinite(start) && Number.isFinite(end) && Number.isFinite(intensity); - }); + // Find peak segments (segments that stand out from their neighbors) + const peakSegments = findPeakSegments(info.heatmap, peakThreshold); - if (validSegments.length === 0) { - console.log("No valid heatmap segments found. Downloading full video..."); + if (peakSegments.length === 0) { + console.log("No significant peak segments found."); + console.log("Downloading full video..."); const outputPath = join(outputDir, `${safeTitle}.%(ext)s`); await downloadSegment(url, outputPath, 0, info.duration, format); return; } - // Find the most watched segment (highest intensity) - const mostWatched = validSegments.reduce((max, current) => { - const currentIntensity = getIntensity(current); - const maxIntensity = getIntensity(max); - return currentIntensity > maxIntensity ? current : max; - }); + // Get the top peak segment + const topPeak = peakSegments[0]; - const startTime = getStartTime(mostWatched); - const endTime = getEndTime(mostWatched); - const intensity = getIntensity(mostWatched); + console.log(`\nTop peak segment:`); + console.log(` Time: ${formatTime(topPeak.start)} - ${formatTime(topPeak.end)}`); + console.log(` Duration: ${formatTime(topPeak.end - topPeak.start)}`); + console.log(` Peak Score: ${(topPeak.peakScore * 100).toFixed(1)}%`); + console.log(` Base Intensity: ${(topPeak.intensity * 100).toFixed(1)}%`); - console.log(`Most watched segment intensity: ${(intensity * 100).toFixed(1)}%`); - console.log(`Segment: ${formatTime(startTime)} - ${formatTime(endTime)}`); + // Download the peak segment + const outputPath = join(outputDir, `${safeTitle}_peak.%(ext)s`); - // Download the most watched segment - const outputPath = join(outputDir, `${safeTitle}_most_watched.%(ext)s`); - - console.log(`\nDownloading most watched segment...`); - await downloadSegment(url, outputPath, startTime, endTime, format); + console.log(`\nDownloading peak segment...`); + await downloadSegment(url, outputPath, topPeak.start, topPeak.end, format); // Save segment info - const segmentInfoPath = join(outputDir, `${safeTitle}_segment_info.txt`); - const segmentInfo = `# ${info.title}\n\n` + - `Most watched segment (from YouTube heatmap):\n` + - ` Start: ${formatTime(startTime)} (${startTime}s)\n` + - ` End: ${formatTime(endTime)} (${endTime}s)\n` + - ` Duration: ${formatTime(endTime - startTime)}\n` + - ` Intensity: ${(intensity * 100).toFixed(1)}%\n\n` + - `Note: This segment had the highest re-watch rate according to YouTube's analytics.\n`; + const segmentInfoPath = join(outputDir, `${safeTitle}_peak_info.txt`); + let segmentInfo = `# ${info.title}\n\n`; + segmentInfo += `Peak segment (stands out from surrounding content):\n`; + segmentInfo += ` Start: ${formatTime(topPeak.start)} (${topPeak.start.toFixed(1)}s)\n`; + segmentInfo += ` End: ${formatTime(topPeak.end)} (${topPeak.end.toFixed(1)}s)\n`; + segmentInfo += ` Duration: ${formatTime(topPeak.end - topPeak.start)}\n`; + segmentInfo += ` Peak Score: ${(topPeak.peakScore * 100).toFixed(1)}%\n`; + segmentInfo += ` Intensity: ${(topPeak.intensity * 100).toFixed(1)}%\n\n`; + + if (peakSegments.length > 1) { + segmentInfo += `Other peaks:\n`; + for (let i = 1; i < Math.min(peakSegments.length, 5); i++) { + const seg = peakSegments[i]; + segmentInfo += ` ${formatTime(seg.start)} - ${formatTime(seg.end)} (score: ${(seg.peakScore * 100).toFixed(1)}%)\n`; + } + } writeFileSync(segmentInfoPath, segmentInfo); console.log(`\nSegment info saved to: ${segmentInfoPath}`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 630295a..1d93903 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,14 +12,15 @@ async function main() { console.log("Options:"); console.log(" -o, --output Output directory (default: ./downloads)"); console.log(" -f, --format Video format (default: best)"); - console.log(" -n, --segments Number of top segments (default: 1)"); + console.log(" -t, --threshold Peak detection threshold (default: 0.3)"); console.log(" -h, --help Show help"); process.exit(1); } - console.log(`Downloading most watched segment from: ${args.url}`); + console.log(`Downloading peak segment from: ${args.url}`); console.log(`Output directory: ${args.output}`); console.log(`Format: ${args.format}`); + console.log(`Peak threshold: ${args.peakThreshold}`); console.log(""); try { @@ -27,6 +28,7 @@ async function main() { url: args.url, outputDir: args.output, format: args.format, + peakThreshold: args.peakThreshold, }); } catch (error) { console.error("Error:", error instanceof Error ? error.message : error);