189 lines
5.8 KiB
TypeScript
189 lines
5.8 KiB
TypeScript
import crypto from 'crypto'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { spawn } from 'child_process'
|
|
import sharp from 'sharp'
|
|
|
|
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
|
const THUMBNAIL_WIDTH = 400
|
|
const JPEG_QUALITY = 75
|
|
|
|
/** Ensure the cache directory exists. */
|
|
function ensureCacheDir(): void {
|
|
if (!fs.existsSync(CACHE_DIR)) {
|
|
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
}
|
|
}
|
|
|
|
/** Compute a stable cache filename from libraryId + absolute file path. */
|
|
function cacheKey(libraryId: string, absoluteFilePath: string): string {
|
|
return crypto
|
|
.createHash('sha1')
|
|
.update(libraryId + ':' + absoluteFilePath)
|
|
.digest('hex')
|
|
}
|
|
|
|
/** Return the cache path for a given source file, or null if the cache is stale/missing. */
|
|
function getCachedPath(cacheFile: string, sourcePath: string): string | null {
|
|
try {
|
|
const cacheStat = fs.statSync(cacheFile)
|
|
const sourceStat = fs.statSync(sourcePath)
|
|
if (cacheStat.mtimeMs >= sourceStat.mtimeMs) {
|
|
return cacheFile
|
|
}
|
|
} catch {
|
|
// Cache miss
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Generate a thumbnail from an image using sharp. */
|
|
async function generateImageThumbnail(src: string, dest: string): Promise<void> {
|
|
const tmp = dest + '.tmp'
|
|
await sharp(src)
|
|
.resize(THUMBNAIL_WIDTH)
|
|
.jpeg({ quality: JPEG_QUALITY })
|
|
.toFile(tmp)
|
|
fs.renameSync(tmp, dest)
|
|
}
|
|
|
|
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
|
function run(bin: string, args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
let stderr = ''
|
|
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
child.on('error', (err) => reject(new Error(`Failed to spawn ${bin}: ${err.message}`)))
|
|
child.on('close', (code) => {
|
|
if (code === 0) resolve()
|
|
else reject(new Error(`${bin} exited with code ${code}: ${stderr.slice(0, 200)}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
/** Get video duration in seconds via ffprobe. */
|
|
async function getVideoDuration(src: string): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const args = [
|
|
'-v', 'quiet',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
src,
|
|
]
|
|
const child = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
let stdout = ''
|
|
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
child.on('error', (err) => reject(new Error(`Failed to spawn ffprobe: ${err.message}`)))
|
|
child.on('close', (code) => {
|
|
if (code !== 0) return reject(new Error(`ffprobe exited with code ${code}`))
|
|
try {
|
|
const json = JSON.parse(stdout) as { format?: { duration?: string } }
|
|
const duration = parseFloat(json.format?.duration ?? '0')
|
|
resolve(isNaN(duration) ? 0 : duration)
|
|
} catch {
|
|
reject(new Error('Failed to parse ffprobe output'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/** Extract a single frame from a video at the given offset (seconds) and write to dest. */
|
|
async function generateVideoFrameAtOffset(src: string, dest: string, offsetSeconds: number): Promise<void> {
|
|
const tmp = dest + '.tmp'
|
|
|
|
const args = [
|
|
'-y', // overwrite output
|
|
'-ss', String(offsetSeconds), // seek before input (fast)
|
|
'-i', src,
|
|
'-frames:v', '1',
|
|
'-q:v', '5',
|
|
'-vf', `scale=${THUMBNAIL_WIDTH}:-1`,
|
|
'-f', 'image2', // explicit output format (avoids ffmpeg guessing from .tmp extension)
|
|
tmp,
|
|
]
|
|
|
|
await run('ffmpeg', args)
|
|
fs.renameSync(tmp, dest)
|
|
}
|
|
|
|
/** Generate a thumbnail from a video using ffmpeg (seeks to 10% of duration). */
|
|
async function generateVideoThumbnail(src: string, dest: string): Promise<void> {
|
|
let offset = 0
|
|
try {
|
|
const duration = await getVideoDuration(src)
|
|
offset = Math.max(0, duration * 0.1)
|
|
} catch {
|
|
// If ffprobe fails, fall back to seeking to 0
|
|
}
|
|
await generateVideoFrameAtOffset(src, dest, offset)
|
|
}
|
|
|
|
/**
|
|
* Extract frames from a video at each given percentage of its duration.
|
|
* Returns the absolute paths to the cached frame JPEGs, in the same order as `percentages`.
|
|
* Uses a per-frame cache key so each frame is cached independently.
|
|
*/
|
|
export async function getVideoFramePaths(
|
|
absoluteFilePath: string,
|
|
libraryId: string,
|
|
percentages: number[]
|
|
): Promise<string[]> {
|
|
ensureCacheDir()
|
|
|
|
let duration = 0
|
|
try {
|
|
duration = await getVideoDuration(absoluteFilePath)
|
|
} catch {
|
|
// Fall back to 0; all frames will seek to position 0
|
|
}
|
|
|
|
const framePaths: string[] = []
|
|
|
|
for (const pct of percentages) {
|
|
const offset = Math.max(0, duration * pct)
|
|
const key = crypto
|
|
.createHash('sha1')
|
|
.update(libraryId + ':' + absoluteFilePath + ':' + pct)
|
|
.digest('hex')
|
|
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
|
|
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
|
if (!cached) {
|
|
await generateVideoFrameAtOffset(absoluteFilePath, cacheFile, offset)
|
|
}
|
|
|
|
framePaths.push(cacheFile)
|
|
}
|
|
|
|
return framePaths
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
|
* Generates it on first call (or when the source has been modified).
|
|
* Throws on failure — callers should map this to a 404.
|
|
*/
|
|
export async function getThumbnailPath(
|
|
absoluteFilePath: string,
|
|
libraryId: string,
|
|
mediaType: 'image' | 'video'
|
|
): Promise<string> {
|
|
ensureCacheDir()
|
|
|
|
const key = cacheKey(libraryId, absoluteFilePath)
|
|
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
|
|
|
// Return from cache if fresh
|
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
|
if (cached) return cached
|
|
|
|
// Generate
|
|
if (mediaType === 'image') {
|
|
await generateImageThumbnail(absoluteFilePath, cacheFile)
|
|
} else {
|
|
await generateVideoThumbnail(absoluteFilePath, cacheFile)
|
|
}
|
|
|
|
return cacheFile
|
|
}
|