Files
boc/quixzoom-video-pipeline/processing/frame-extractor.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

331 lines
8.7 KiB
JavaScript

/**
* 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,
};