/** * QUIXZOOM Video Pipeline — Frame Extractor * * Extraherar frames från videor med olika strategier: * - Varje X sekunder * - Varje X meter (om GPS finns) * - Vid scenbyten * - Vid hög rörelse * * Teknik: Node.js, FFmpeg, Sharp */ const { exec } = require('child_process'); const { promisify } = require('util'); const fs = require('fs').promises; const path = require('path'); const sharp = require('sharp'); const execAsync = promisify(exec); // Konfiguration const FRAME_STRATEGIES = { // Extrahera var N:e sekund timeBased: { enabled: true, interval: 5, // sekunder }, // Extrahera vid GPS-intervall (om tillgängligt) distanceBased: { enabled: true, interval: 10, // meter }, // Extrahera vid scenbyten sceneChange: { enabled: true, threshold: 0.3, // 0-1, högre = mer känslig }, // Extrahera vid hög rörelse motionBased: { enabled: true, threshold: 0.1, // 0-1 }, }; /** * Extrahera frames från video */ async function extractFrames(videoPath, outputDir, options = {}) { const videoId = path.basename(videoPath, path.extname(videoPath)); const framesDir = path.join(outputDir, videoId, 'frames'); // Skapa output-katalog await fs.mkdir(framesDir, { recursive: true }); console.log(`[EXTRACTOR] Processing: ${videoPath}`); const frames = []; // Strategi 1: Tidsbaserad if (FRAME_STRATEGIES.timeBased.enabled) { const timeFrames = await extractTimeBasedFrames( videoPath, framesDir, options.timeInterval || FRAME_STRATEGIES.timeBased.interval ); frames.push(...timeFrames); } // Strategi 2: Scenförändring if (FRAME_STRATEGIES.sceneChange.enabled) { const sceneFrames = await extractSceneChangeFrames( videoPath, framesDir, options.sceneThreshold || FRAME_STRATEGIES.sceneChange.threshold ); frames.push(...sceneFrames); } // Strategi 3: Rörelsebaserad if (FRAME_STRATEGIES.motionBased.enabled) { const motionFrames = await extractMotionBasedFrames( videoPath, framesDir, options.motionThreshold || FRAME_STRATEGIES.motionBased.threshold ); frames.push(...motionFrames); } // Deduplicera frames (ta bort för nära liggande) const uniqueFrames = deduplicateFrames(frames); // Generera thumbnails for (const frame of uniqueFrames) { await generateThumbnails(frame.path, framesDir); } console.log(`[EXTRACTOR] Extracted ${uniqueFrames.length} unique frames`); return { videoId, totalFrames: uniqueFrames.length, frames: uniqueFrames, outputDir: framesDir, }; } /** * Tidsbaserad frame-extraktion */ async function extractTimeBasedFrames(videoPath, outputDir, interval) { const frames = []; const outputPattern = path.join(outputDir, 'time_%04d.jpg'); // Kör FFmpeg const command = `ffmpeg -i "${videoPath}" -vf "fps=1/${interval},scale=1920:1080:force_original_aspect_ratio=decrease" -q:v 2 "${outputPattern}"`; try { await execAsync(command); // Lista genererade frames const files = await fs.readdir(outputDir); const timeFiles = files.filter(f => f.startsWith('time_')); for (let i = 0; i < timeFiles.length; i++) { const timestamp = i * interval; frames.push({ path: path.join(outputDir, timeFiles[i]), timestamp, strategy: 'timeBased', frameNumber: i, }); } } catch (error) { console.error('[EXTRACTOR] Time-based extraction failed:', error.message); } return frames; } /** * Scenförändringsbaserad frame-extraktion */ async function extractSceneChangeFrames(videoPath, outputDir, threshold) { const frames = []; const outputPattern = path.join(outputDir, 'scene_%04d.jpg'); // FFmpeg scen-detektering const detectCommand = `ffmpeg -i "${videoPath}" -vf "select='gt(scene,${threshold})',scale=1920:1080:force_original_aspect_ratio=decrease" -vsync vfr -q:v 2 "${outputPattern}"`; try { await execAsync(detectCommand); // Hämta scen-timestamps const infoCommand = `ffmpeg -i "${videoPath}" -vf "select='gt(scene,${threshold})',showinfo" -f null - 2>&1 | grep "pts_time:"`; const { stdout } = await execAsync(infoCommand); const timestamps = stdout .split('\n') .map(line => { const match = line.match(/pts_time:(\d+\.?\d*)/); return match ? parseFloat(match[1]) : null; }) .filter(t => t !== null); const files = await fs.readdir(outputDir); const sceneFiles = files.filter(f => f.startsWith('scene_')); for (let i = 0; i < sceneFiles.length && i < timestamps.length; i++) { frames.push({ path: path.join(outputDir, sceneFiles[i]), timestamp: timestamps[i], strategy: 'sceneChange', frameNumber: i, }); } } catch (error) { console.error('[EXTRACTOR] Scene change extraction failed:', error.message); } return frames; } /** * Rörelsebaserad frame-extraktion */ async function extractMotionBasedFrames(videoPath, outputDir, threshold) { const frames = []; const outputPattern = path.join(outputDir, 'motion_%04d.jpg'); // FFmpeg motion-detektering const command = `ffmpeg -i "${videoPath}" -vf "select='gt(scene,${threshold})',scale=1920:1080:force_original_aspect_ratio=decrease" -vsync vfr -q:v 2 "${outputPattern}"`; try { await execAsync(command); const files = await fs.readdir(outputDir); const motionFiles = files.filter(f => f.startsWith('motion_')); for (let i = 0; i < motionFiles.length; i++) { frames.push({ path: path.join(outputDir, motionFiles[i]), timestamp: null, // Beräknas senare strategy: 'motionBased', frameNumber: i, }); } } catch (error) { console.error('[EXTRACTOR] Motion-based extraction failed:', error.message); } return frames; } /** * Deduplicera frames (ta bort för nära liggande) */ function deduplicateFrames(frames, minInterval = 2) { // Sortera efter timestamp const sorted = frames .filter(f => f.timestamp !== null) .sort((a, b) => a.timestamp - b.timestamp); const unique = []; let lastTimestamp = -Infinity; for (const frame of sorted) { if (frame.timestamp - lastTimestamp >= minInterval) { unique.push(frame); lastTimestamp = frame.timestamp; } } return unique; } /** * Generera thumbnails */ async function generateThumbnails(framePath, outputDir) { const baseName = path.basename(framePath, path.extname(framePath)); const sizes = [ { name: 'thumbnail', width: 200, height: 200 }, { name: 'preview', width: 800, height: 600 }, { name: 'medium', width: 1600, height: 1200 }, ]; for (const size of sizes) { const outputPath = path.join(outputDir, `${baseName}_${size.name}.jpg`); await sharp(framePath) .resize(size.width, size.height, { fit: 'inside', withoutEnlargement: true, }) .jpeg({ quality: 85 }) .toFile(outputPath); } } /** * Extrahera metadata från video */ async function extractVideoMetadata(videoPath) { const command = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`; try { const { stdout } = await execAsync(command); const metadata = JSON.parse(stdout); return { duration: parseFloat(metadata.format.duration), size: parseInt(metadata.format.size), bitrate: parseInt(metadata.format.bit_rate), format: metadata.format.format_name, width: metadata.streams[0].width, height: metadata.streams[0].height, fps: eval(metadata.streams[0].r_frame_rate), // "30/1" → 30 codec: metadata.streams[0].codec_name, }; } catch (error) { console.error('[EXTRACTOR] Metadata extraction failed:', error.message); return null; } } /** * Huvudfunktion — processa en video */ async function processVideo(videoPath, outputDir, options = {}) { console.log(`[EXTRACTOR] Starting processing: ${videoPath}`); // Extrahera metadata const metadata = await extractVideoMetadata(videoPath); if (!metadata) { throw new Error('Failed to extract video metadata'); } console.log(`[EXTRACTOR] Video: ${metadata.width}x${metadata.height}, ${metadata.duration}s, ${metadata.fps}fps`); // Extrahera frames const result = await extractFrames(videoPath, outputDir, options); // Spara metadata const metaPath = path.join(outputDir, result.videoId, 'metadata.json'); await fs.writeFile(metaPath, JSON.stringify({ videoId: result.videoId, videoPath, videoMetadata: metadata, extractionOptions: options, extractionResult: result, processedAt: new Date().toISOString(), }, null, 2)); return result; } module.exports = { extractFrames, extractTimeBasedFrames, extractSceneChangeFrames, extractMotionBasedFrames, deduplicateFrames, generateThumbnails, extractVideoMetadata, processVideo, };