import axios from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import { config } from 'dotenv'; import { execSync } from 'child_process'; import { logInfo, resolveProjectPath } from '../runtime'; // @ts-ignore - ffprobe-static types import ffprobePath from 'ffprobe-static'; // Load environment variables from .env file config({ path: resolveProjectPath('.env') }); const console = { log: (...args: unknown[]) => logInfo(...args), }; export interface MediaAsset { type: 'image' | 'video'; url: string; width: number; height: number; photographer?: string; localPath?: string; videoDuration?: number; // Duration in seconds for video files videoTrimAfterFrames?: number; } const PEXELS_API_KEY = process.env.PEXELS_API_KEY || ''; const PIXABAY_API_KEY = process.env.PIXABAY_API_KEY || ''; const BASE_URL = 'https://api.pexels.com/v1'; const CACHE_FILE = resolveProjectPath('.video-cache.json'); const MAX_DOWNLOAD_BYTES = 40 * 1024 * 1024; const DOWNLOAD_STALL_TIMEOUT_MS = 15000; const TARGET_VIDEO_DURATION_SECONDS = 6; const DEFAULT_RENDER_FPS = 30; const SAFE_VIDEO_END_BUFFER_FRAMES = 3; // Preferred video quality order (highest first) const PREFERRED_QUALITIES = ['uhd', 'hd', 'sd']; const MIN_WIDTH = 720; // Minimum acceptable video width // Log API key status on load // console.log('\nšŸ”‘ [VISUAL-FETCHER] Module loaded'); // console.log(`šŸ”‘ [VISUAL-FETCHER] Pexels API Key: ${PEXELS_API_KEY ? `${PEXELS_API_KEY.substring(0, 8)}...` : 'āŒ NOT SET'}`); // console.log(`šŸ”‘ [VISUAL-FETCHER] Cache file: ${CACHE_FILE}`); // Simple cache for video URLs interface VideoCache { [keywords: string]: MediaAsset; } function loadCache(): VideoCache { // console.log('šŸ“¦ [CACHE] Loading cache...'); try { if (fs.existsSync(CACHE_FILE)) { const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')); const entries = Object.keys(cache).length; // console.log(`šŸ“¦ [CACHE] Loaded ${entries} cached entries`); return cache; } // console.log('šŸ“¦ [CACHE] No cache file found, starting fresh'); } catch (error: any) { // console.error(`šŸ“¦ [CACHE] Error loading cache: ${error.message}`); } return {}; } // In-memory singleton cache to prevent concurrency issues let inMemoryCache: VideoCache | null = null; function getCache(): VideoCache { if (inMemoryCache) return inMemoryCache; inMemoryCache = loadCache(); return inMemoryCache; } /** * Explicitly reset the in-memory cache */ export function resetInMemoryCache(): void { inMemoryCache = {}; if (fs.existsSync(CACHE_FILE)) { try { fs.unlinkSync(CACHE_FILE); } catch (e) { } } } export function invalidateCachedVisual( keywords: string[], orientation: 'portrait' | 'landscape' = 'portrait' ): void { const cacheKey = `${keywords.join(' ').toLowerCase()}:${orientation}`; const cache = getCache(); if (cache[cacheKey]) { delete cache[cacheKey]; saveCache(cache); } } function saveCache(cache: VideoCache): void { try { const entries = Object.keys(cache).length; fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)); // console.log(`šŸ“¦ [CACHE] Saved ${entries} entries to cache`); } catch (error: any) { // console.error(`šŸ“¦ [CACHE] Error saving cache: ${error.message}`); } } /** * Sleep utility for retry delays */ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } export interface VideoMetadata { durationSeconds: number; trimAfterFrames: number; } const parsePositiveNumber = (value: unknown): number | undefined => { if (typeof value === 'number') { return Number.isFinite(value) && value > 0 ? value : undefined; } if (typeof value === 'string') { const parsed = parseFloat(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } return undefined; }; const parsePositiveInteger = (value: unknown): number | undefined => { if (typeof value === 'number') { return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; } if (typeof value === 'string') { const parsed = parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } return undefined; }; const parseFrameRate = (value: unknown): number | undefined => { if (typeof value !== 'string' || value.trim().length === 0) { return undefined; } if (!value.includes('/')) { return parsePositiveNumber(value); } const [numerator, denominator] = value.split('/'); const top = parseFloat(numerator); const bottom = parseFloat(denominator); if (!Number.isFinite(top) || !Number.isFinite(bottom) || bottom === 0) { return undefined; } const rate = top / bottom; return Number.isFinite(rate) && rate > 0 ? rate : undefined; }; const estimateVideoDurationFromSize = (filePath: string): number | undefined => { try { const stats = fs.statSync(filePath); const sizeMB = stats.size / (1024 * 1024); return Math.max(3, Math.min(30, sizeMB * 0.5)); } catch { return undefined; } }; const calculateSafeTrimAfterFrames = ( durationSeconds: number, renderFps: number = DEFAULT_RENDER_FPS ): number => { const durationFrames = Math.max(1, Math.floor(durationSeconds * renderFps)); return Math.max(1, durationFrames - SAFE_VIDEO_END_BUFFER_FRAMES); }; /** * Get conservative video metadata for Remotion rendering. * We trim a few frames from the end because some stock clips report a * slightly longer duration than the actually seekable final frame. */ export function getVideoMetadata( filePath: string, renderFps: number = DEFAULT_RENDER_FPS ): VideoMetadata { try { const ffprobeCmd = ffprobePath.path || 'ffprobe'; const result = execSync( `"${ffprobeCmd}" -v quiet -count_frames -print_format json -show_entries format=duration:stream=codec_type,duration,avg_frame_rate,r_frame_rate,nb_frames,nb_read_frames "${filePath}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } ); const parsed = JSON.parse(result) as { format?: { duration?: string }; streams?: Array<{ codec_type?: string; duration?: string; avg_frame_rate?: string; r_frame_rate?: string; nb_frames?: string; nb_read_frames?: string; }>; }; const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video') ?? parsed.streams?.[0]; const formatDuration = parsePositiveNumber(parsed.format?.duration); const streamDuration = parsePositiveNumber(videoStream?.duration); const rawDuration = streamDuration ?? formatDuration; const sourceFrameRate = parseFrameRate(videoStream?.avg_frame_rate) ?? parseFrameRate(videoStream?.r_frame_rate); const sourceFrameCount = parsePositiveInteger(videoStream?.nb_read_frames) ?? parsePositiveInteger(videoStream?.nb_frames); let measuredDuration = rawDuration; if (sourceFrameCount && sourceFrameRate) { const frameBasedDuration = sourceFrameCount / sourceFrameRate; measuredDuration = measuredDuration ? Math.min(measuredDuration, frameBasedDuration) : frameBasedDuration; } if (measuredDuration) { return { durationSeconds: measuredDuration, trimAfterFrames: calculateSafeTrimAfterFrames(measuredDuration, renderFps), }; } } catch { // ffprobe not available or returned invalid metadata } const estimatedDuration = estimateVideoDurationFromSize(filePath); if (estimatedDuration) { return { durationSeconds: estimatedDuration, trimAfterFrames: calculateSafeTrimAfterFrames(estimatedDuration, renderFps), }; } return { durationSeconds: 5, trimAfterFrames: calculateSafeTrimAfterFrames(5, renderFps), }; } /** * Get video duration in seconds using ffprobe * Falls back to file size estimation if ffprobe unavailable */ export function getVideoDuration(filePath: string): number { return getVideoMetadata(filePath).durationSeconds; } /** * Select the best quality video file */ function selectBestVideoFile(videoFiles: any[]): any { // console.log(` šŸŽ¬ [QUALITY] Selecting best from ${videoFiles.length} video files`); // Log available qualities const qualities = videoFiles.map(f => `${f.quality} (${f.width}x${f.height})`); // console.log(` šŸŽ¬ [QUALITY] Available: ${qualities.join(', ')}`); // Filter out videos that are too small const validFiles = videoFiles.filter(f => f.width >= MIN_WIDTH); // console.log(` šŸŽ¬ [QUALITY] Files >= ${MIN_WIDTH}px width: ${validFiles.length}`); if (validFiles.length === 0) { // console.log(` šŸŽ¬ [QUALITY] No valid files, using first available`); return videoFiles[0]; } // Find best quality for (const quality of PREFERRED_QUALITIES) { const match = validFiles.find((f: any) => f.quality === quality); if (match) { // console.log(` šŸŽ¬ [QUALITY] Selected: ${quality} (${match.width}x${match.height})`); return match; } } // Fallback to highest width const sorted = validFiles.sort((a, b) => b.width - a.width)[0]; // console.log(` šŸŽ¬ [QUALITY] Fallback to largest: ${sorted.width}x${sorted.height}`); return sorted; } function scoreVideoAsset(asset: MediaAsset): number { const duration = asset.videoDuration || TARGET_VIDEO_DURATION_SECONDS; const durationDelta = Math.abs(duration - TARGET_VIDEO_DURATION_SECONDS); const pixelCount = asset.width * asset.height; return (durationDelta * 1_000_000) + pixelCount; } /** * Search for video footage on Pexels with retry logic */ export async function searchVideos( query: string, perPage: number = 1, retries: number = 3, orientation: 'portrait' | 'landscape' = 'portrait' ): Promise { // console.log(`\nšŸ” [PEXELS-VIDEO] Searching videos for: "${query}"`); // console.log(`šŸ” [PEXELS-VIDEO] Per page: ${perPage}, Max retries: ${retries}`); if (!PEXELS_API_KEY) { // console.warn('šŸ” [PEXELS-VIDEO] āŒ No API key - returning empty result'); return []; } for (let attempt = 1; attempt <= retries; attempt++) { // console.log(`šŸ” [PEXELS-VIDEO] Attempt ${attempt}/${retries}...`); const startTime = Date.now(); try { const response = await axios.get(`https://api.pexels.com/videos/search`, { headers: { Authorization: PEXELS_API_KEY, }, params: { query, per_page: perPage, orientation, }, timeout: 10000, }); const elapsed = Date.now() - startTime; // console.log(`šŸ” [PEXELS-VIDEO] Response received in ${elapsed}ms`); // console.log(`šŸ” [PEXELS-VIDEO] Status: ${response.status}`); // console.log(`šŸ” [PEXELS-VIDEO] Videos found: ${response.data.videos?.length || 0}`); if (!response.data.videos || response.data.videos.length === 0) { // console.log(`šŸ” [PEXELS-VIDEO] No videos in response`); return []; } const assets = response.data.videos.map((video: any, idx: number) => { // console.log(` šŸŽ„ [VIDEO ${idx + 1}] ID: ${video.id}, By: ${video.user.name}`); // console.log(` šŸŽ„ [VIDEO ${idx + 1}] Files: ${video.video_files.length}`); const bestFile = selectBestVideoFile(video.video_files); return { type: 'video' as const, url: bestFile.link, width: bestFile.width, height: bestFile.height, photographer: video.user.name, videoDuration: video.duration, }; }); return assets.sort((left: MediaAsset, right: MediaAsset) => scoreVideoAsset(left) - scoreVideoAsset(right)); } catch (error: any) { const elapsed = Date.now() - startTime; // console.error(`šŸ” [PEXELS-VIDEO] āŒ Error after ${elapsed}ms: ${error.message}`); if (error.response) { // console.error(`šŸ” [PEXELS-VIDEO] Status: ${error.response.status}`); // console.error(`šŸ” [PEXELS-VIDEO] Data: ${JSON.stringify(error.response.data)}`); } if (attempt < retries) { const delay = 1000 * attempt; // console.warn(`šŸ” [PEXELS-VIDEO] Retrying in ${delay}ms...`); await sleep(delay); } else { // console.error('šŸ” [PEXELS-VIDEO] All retries exhausted'); return []; } } } return []; } /** * Search for images on Pexels with retry logic */ export async function searchImages( query: string, perPage: number = 1, retries: number = 3, orientation: 'portrait' | 'landscape' = 'portrait' ): Promise { // console.log(`\nšŸ” [PEXELS-IMAGE] Searching images for: "${query}"`); // console.log(`šŸ” [PEXELS-IMAGE] Per page: ${perPage}, Max retries: ${retries}`); if (!PEXELS_API_KEY) { // console.warn('šŸ” [PEXELS-IMAGE] āŒ No API key - returning empty result'); return []; } for (let attempt = 1; attempt <= retries; attempt++) { // console.log(`šŸ” [PEXELS-IMAGE] Attempt ${attempt}/${retries}...`); const startTime = Date.now(); try { const response = await axios.get(`${BASE_URL}/search`, { headers: { Authorization: PEXELS_API_KEY, }, params: { query, per_page: perPage, orientation, }, timeout: 10000, }); const elapsed = Date.now() - startTime; // console.log(`šŸ” [PEXELS-IMAGE] Response received in ${elapsed}ms`); // console.log(`šŸ” [PEXELS-IMAGE] Status: ${response.status}`); // console.log(`šŸ” [PEXELS-IMAGE] Photos found: ${response.data.photos?.length || 0}`); if (!response.data.photos || response.data.photos.length === 0) { // console.log(`šŸ” [PEXELS-IMAGE] No photos in response`); return []; } return response.data.photos.map((photo: any, idx: number) => { // console.log(` šŸ–¼ļø [PHOTO ${idx + 1}] ID: ${photo.id}, By: ${photo.photographer}`); // console.log(` šŸ–¼ļø [PHOTO ${idx + 1}] Size: ${photo.width}x${photo.height}`); return { type: 'image' as const, url: photo.src.large2x, width: photo.width, height: photo.height, photographer: photo.photographer, }; }); } catch (error: any) { const elapsed = Date.now() - startTime; // console.error(`šŸ” [PEXELS-IMAGE] āŒ Error after ${elapsed}ms: ${error.message}`); if (error.response) { // console.error(`šŸ” [PEXELS-IMAGE] Status: ${error.response.status}`); // console.error(`šŸ” [PEXELS-IMAGE] Data: ${JSON.stringify(error.response.data)}`); } if (attempt < retries) { const delay = 1000 * attempt; // console.warn(`šŸ” [PEXELS-IMAGE] Retrying in ${delay}ms...`); await sleep(delay); } else { // console.error('šŸ” [PEXELS-IMAGE] All retries exhausted'); return []; } } } return []; } /** * Search for videos on Pixabay */ export async function searchPixabayVideos( query: string, perPage: number = 3, retries: number = 3, orientation: 'portrait' | 'landscape' = 'portrait' ): Promise { // console.log(`\nšŸ” [PIXABAY-VIDEO] Searching videos for: "${query}"`); if (!PIXABAY_API_KEY) { // console.warn('šŸ” [PIXABAY-VIDEO] āŒ No API key - skipping'); return []; } const pixabayOrientation = orientation === 'landscape' ? 'horizontal' : 'vertical'; for (let attempt = 1; attempt <= retries; attempt++) { // console.log(`šŸ” [PIXABAY-VIDEO] Attempt ${attempt}/${retries}...`); const startTime = Date.now(); try { const response = await axios.get(`https://pixabay.com/api/videos/`, { params: { key: PIXABAY_API_KEY, q: query, video_type: 'film', orientation: pixabayOrientation, per_page: perPage, min_width: 1280 // Prefer HD+ }, timeout: 10000, }); const elapsed = Date.now() - startTime; // console.log(`šŸ” [PIXABAY-VIDEO] Response received in ${elapsed}ms`); if (!response.data.hits || response.data.hits.length === 0) { // console.log(`šŸ” [PIXABAY-VIDEO] No videos found`); return []; } return response.data.hits.map((hit: any) => { // Pixabay returns 'videos' object with sizes const sizes = hit.videos; // Prefer Large > Medium > Small const bestFile = sizes.large.url ? sizes.large : (sizes.medium.url ? sizes.medium : sizes.small); return { type: 'video' as const, url: bestFile.url, width: bestFile.width, height: bestFile.height, photographer: hit.user, videoDuration: hit.duration }; }); } catch (error: any) { // console.error(`šŸ” [PIXABAY-VIDEO] āŒ Error: ${error.message}`); if (attempt < retries) { await sleep(1000 * attempt); } else { return []; } } } return []; } /** * Fetch visuals for a scene based on keywords (with caching) */ export async function fetchVisualsForScene( keywords: string[], preferVideo: boolean = true, orientation: 'portrait' | 'landscape' = 'portrait' ): Promise { const query = keywords.join(' '); const cacheKey = `${query.toLowerCase()}:${orientation}`; // console.log('\nšŸŽØ ════════════════════════════════════════════════'); // console.log(`šŸŽØ [FETCH] Fetching visuals for keywords: [${keywords.join(', ')}]`); // console.log(`šŸŽØ [FETCH] Query: "${query}"`); // console.log(`šŸŽØ [FETCH] Prefer video: ${preferVideo}`); // Check cache first const cache = getCache(); if (cache[cacheKey]) { // console.log(`šŸŽØ [FETCH] āœ… CACHE HIT! Using cached ${cache[cacheKey].type}`); // console.log(`šŸŽØ [FETCH] URL: ${cache[cacheKey].url}`); return cache[cacheKey]; } console.log(`šŸŽØ [FETCH] Cache miss for "${query}", fetching from API...`); try { if (preferVideo) { // console.log(`šŸŽØ [FETCH] Trying video search first...`); const videos = await searchVideos(query, 1, 3, orientation); if (videos.length > 0) { // console.log(`šŸŽØ [FETCH] āœ… Found video: ${videos[0].url}`); // console.log(`šŸŽØ [FETCH] Resolution: ${videos[0].width}x${videos[0].height}`); cache[cacheKey] = videos[0]; saveCache(cache); return videos[0]; } // console.log(`šŸŽØ [FETCH] No videos found on Pexels, trying Pixabay...`); // Fallback to Pixabay const pixabayVideos = await searchPixabayVideos(query, 3, 3, orientation); if (pixabayVideos.length > 0) { // console.log(`šŸŽØ [FETCH] āœ… Found Pixabay video: ${pixabayVideos[0].url}`); cache[cacheKey] = pixabayVideos[0]; saveCache(cache); return pixabayVideos[0]; } // console.log(`šŸŽØ [FETCH] No videos found on Pixabay either, trying images...`); } // Fallback to images const images = await searchImages(query, 1, 3, orientation); if (images.length > 0) { // console.log(`šŸŽØ [FETCH] āœ… Found image: ${images[0].url}`); cache[cacheKey] = images[0]; saveCache(cache); return images[0]; } // console.log('šŸŽØ [FETCH] āš ļø No visuals found for this query'); return null; } catch (error: any) { // console.error(`šŸŽØ [FETCH] āŒ Error: ${error.message}`); // console.error(`šŸŽØ [FETCH] Stack: ${error.stack}`); return null; } } /** * Download result with path and optional video duration */ export interface DownloadResult { path: string; videoDuration?: number; // Duration in seconds for video files videoTrimAfterFrames?: number; } /** * Download a media file to local storage with retry logic * Returns path and video duration (if video) */ export async function downloadMedia( url: string, outputDir: string, filename: string, retries: number = 3 ): Promise { const outputPath = path.join(outputDir, filename); // console.log(`\nā¬‡ļø [DOWNLOAD] Starting download...`); // console.log(`ā¬‡ļø [DOWNLOAD] URL: ${url}`); // console.log(`ā¬‡ļø [DOWNLOAD] Output: ${outputPath}`); // console.log(`ā¬‡ļø [DOWNLOAD] Max retries: ${retries}`); // Create directory if it doesn't exist if (!fs.existsSync(outputDir)) { // console.log(`ā¬‡ļø [DOWNLOAD] Creating directory: ${outputDir}`); fs.mkdirSync(outputDir, { recursive: true }); } // RESUME CHECK: If file exists and is valid, skip download if (fs.existsSync(outputPath)) { try { const stats = fs.statSync(outputPath); if (stats.size > 1024) { // Ignore > 1KB files // Get video duration if it's a video file let videoDuration: number | undefined; let videoTrimAfterFrames: number | undefined; if (filename.endsWith('.mp4') || filename.endsWith('.webm') || filename.endsWith('.mov')) { const videoMetadata = getVideoMetadata(outputPath); videoDuration = videoMetadata.durationSeconds; videoTrimAfterFrames = videoMetadata.trimAfterFrames; } // console.log(`ā¬‡ļø [DOWNLOAD] File exists, skipping download: ${filename}`); return { path: outputPath, videoDuration, videoTrimAfterFrames }; } } catch (e) { // Check failed, proceed to download } } for (let attempt = 1; attempt <= retries; attempt++) { // console.log(`ā¬‡ļø [DOWNLOAD] Attempt ${attempt}/${retries}...`); const startTime = Date.now(); try { const response = await axios.get(url, { responseType: 'stream', timeout: 60000, }); // console.log(`ā¬‡ļø [DOWNLOAD] Response status: ${response.status}`); // console.log(`ā¬‡ļø [DOWNLOAD] Content-Type: ${response.headers['content-type']}`); // console.log(`ā¬‡ļø [DOWNLOAD] Content-Length: ${response.headers['content-length']} bytes`); const writer = fs.createWriteStream(outputPath); let settled = false; let stallTimer: NodeJS.Timeout | null = null; const clearStallTimer = () => { if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; } }; const refreshStallTimer = () => { clearStallTimer(); stallTimer = setTimeout(() => { response.data.destroy(new Error(`Download stalled for ${filename}`)); }, DOWNLOAD_STALL_TIMEOUT_MS); }; const contentLength = Number(response.headers['content-length'] || '0'); if (contentLength > MAX_DOWNLOAD_BYTES) { writer.destroy(); response.data.destroy(); throw new Error(`File too large to download (${contentLength} bytes): ${filename}`); } return await new Promise((resolve, reject) => { refreshStallTimer(); response.data.on('data', () => refreshStallTimer()); response.data.on('error', (err: Error) => { if (settled) { return; } settled = true; clearStallTimer(); writer.destroy(); reject(err); }); response.data.pipe(writer); const finalize = () => { if (settled) { return; } settled = true; clearStallTimer(); const elapsed = Date.now() - startTime; if (!fs.existsSync(outputPath)) { reject(new Error(`Downloaded file missing after write: ${outputPath}`)); return; } const stats = fs.statSync(outputPath); // console.log(`ā¬‡ļø [DOWNLOAD] āœ… Complete in ${elapsed}ms`); // console.log(`ā¬‡ļø [DOWNLOAD] File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); // Get video duration if it's a video file let videoDuration: number | undefined; let videoTrimAfterFrames: number | undefined; if (filename.endsWith('.mp4') || filename.endsWith('.webm') || filename.endsWith('.mov')) { const videoMetadata = getVideoMetadata(outputPath); videoDuration = videoMetadata.durationSeconds; videoTrimAfterFrames = videoMetadata.trimAfterFrames; } resolve({ path: outputPath, videoDuration, videoTrimAfterFrames }); }; writer.on('finish', () => undefined); writer.on('close', finalize); writer.on('error', (err) => { if (settled) { return; } settled = true; clearStallTimer(); // console.error(`ā¬‡ļø [DOWNLOAD] āŒ Write error: ${err.message}`); reject(err); }); }); } catch (error: any) { const elapsed = Date.now() - startTime; // console.error(`ā¬‡ļø [DOWNLOAD] āŒ Error after ${elapsed}ms: ${error.message}`); if (error.response) { // console.error(`ā¬‡ļø [DOWNLOAD] Status: ${error.response.status}`); } if (attempt < retries) { const delay = 1000 * attempt; // console.warn(`ā¬‡ļø [DOWNLOAD] Retrying in ${delay}ms...`); await sleep(delay); } else { // console.error('ā¬‡ļø [DOWNLOAD] All retries exhausted, throwing error'); throw error; } } } throw new Error('Download failed after all retries'); }