punimtag/viewer-frontend/lib/video-thumbnail.ts
Tanya b6a9765315
Some checks failed
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
chore: Update project configuration and enhance code quality
This commit modifies the `.gitignore` file to exclude Python library directories while ensuring the viewer-frontend's `lib` directory is not ignored. It also updates the `package.json` to activate the virtual environment during backend tests, improving the testing process. Additionally, the CI workflow is enhanced to prevent duplicate runs for branches with open pull requests. Various components in the viewer frontend are updated to ensure consistent naming conventions and improve type safety. These changes contribute to a cleaner codebase and a more efficient development workflow.
2026-01-07 12:29:17 -05:00

167 lines
5.4 KiB
TypeScript

import { existsSync, mkdirSync } from 'fs';
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// Thumbnail cache directory (relative to project root)
const THUMBNAIL_CACHE_DIR = path.join(process.cwd(), '.cache', 'video-thumbnails');
const THUMBNAIL_QUALITY = 85; // JPEG quality
const THUMBNAIL_MAX_WIDTH = 800; // Max width for thumbnails
// Ensure cache directory exists
function ensureCacheDir() {
if (!existsSync(THUMBNAIL_CACHE_DIR)) {
mkdirSync(THUMBNAIL_CACHE_DIR, { recursive: true });
}
}
/**
* Get thumbnail path for a video file
*/
function getThumbnailPath(videoPath: string): string {
ensureCacheDir();
// Create a hash-like filename from the video path
// Use a simple approach: replace path separators and special chars
const safePath = videoPath.replace(/[^a-zA-Z0-9]/g, '_');
const hash = Buffer.from(safePath).toString('base64').slice(0, 32);
return path.join(THUMBNAIL_CACHE_DIR, `${hash}.jpg`);
}
/**
* Check if ffmpeg is available
*/
async function isFfmpegAvailable(): Promise<boolean> {
try {
await execAsync('ffmpeg -version');
return true;
} catch {
return false;
}
}
/**
* Generate video thumbnail using ffmpeg
* Extracts frame at 1 second (or first frame if video is shorter)
*/
async function generateThumbnailWithFfmpeg(
videoPath: string,
thumbnailPath: string
): Promise<Buffer> {
// Escape paths properly for shell commands
const escapedVideoPath = videoPath.replace(/'/g, "'\"'\"'");
const escapedThumbnailPath = thumbnailPath.replace(/'/g, "'\"'\"'");
// Extract frame at 1 second, or first frame if video is shorter
// Scale to max width while maintaining aspect ratio
// Use -loglevel error to suppress ffmpeg output unless there's an error
const command = `ffmpeg -i '${escapedVideoPath}' -ss 00:00:01 -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
try {
const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 });
if (stderr && !stderr.includes('frame=')) {
console.warn('ffmpeg stderr:', stderr);
}
// Wait a bit for file to be written
await new Promise(resolve => setTimeout(resolve, 100));
// Read the generated thumbnail
if (existsSync(thumbnailPath)) {
const buffer = await readFile(thumbnailPath);
if (buffer.length > 0) {
return buffer;
}
throw new Error('Thumbnail file is empty');
}
throw new Error('Thumbnail file was not created');
} catch (error) {
console.error(`Error extracting frame at 1s for ${videoPath}:`, error);
// If extraction at 1s fails, try first frame
try {
const fallbackCommand = `ffmpeg -i '${escapedVideoPath}' -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
const { stdout, stderr } = await execAsync(fallbackCommand, { maxBuffer: 10 * 1024 * 1024 });
if (stderr && !stderr.includes('frame=')) {
console.warn('ffmpeg fallback stderr:', stderr);
}
// Wait a bit for file to be written
await new Promise(resolve => setTimeout(resolve, 100));
if (existsSync(thumbnailPath)) {
const buffer = await readFile(thumbnailPath);
if (buffer.length > 0) {
return buffer;
}
throw new Error('Fallback thumbnail file is empty');
}
} catch (fallbackError) {
console.error(`Error generating video thumbnail (fallback failed) for ${videoPath}:`, fallbackError);
throw new Error(`Failed to generate video thumbnail: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown error'}`);
}
throw error;
}
}
/**
* Generate or retrieve cached video thumbnail
* Returns thumbnail buffer if successful, null if ffmpeg is not available
*/
export async function getVideoThumbnail(videoPath: string): Promise<Buffer | null> {
// Check if ffmpeg is available
const ffmpegAvailable = await isFfmpegAvailable();
if (!ffmpegAvailable) {
console.warn('ffmpeg is not available. Video thumbnails will not be generated.');
return null;
}
const thumbnailPath = getThumbnailPath(videoPath);
// Check if thumbnail already exists in cache
if (existsSync(thumbnailPath)) {
try {
return await readFile(thumbnailPath);
} catch (error) {
console.error('Error reading cached thumbnail:', error);
// Continue to regenerate
}
}
// Check if video file exists
if (!existsSync(videoPath)) {
console.error(`Video file not found: ${videoPath}`);
return null;
}
// Generate new thumbnail
try {
const thumbnailBuffer = await generateThumbnailWithFfmpeg(videoPath, thumbnailPath);
return thumbnailBuffer;
} catch (error) {
console.error(`Error generating thumbnail for ${videoPath}:`, error);
return null;
}
}
/**
* Check if a thumbnail exists in cache
*/
export function hasCachedThumbnail(videoPath: string): boolean {
const thumbnailPath = getThumbnailPath(videoPath);
return existsSync(thumbnailPath);
}
/**
* Get cached thumbnail path (for direct file serving)
*/
export function getCachedThumbnailPath(videoPath: string): string | null {
const thumbnailPath = getThumbnailPath(videoPath);
return existsSync(thumbnailPath) ? thumbnailPath : null;
}