bae705aa97
- 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
331 lines
8.7 KiB
JavaScript
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,
|
|
};
|