feat: add thumbnail timeout and low-memory warning

This commit is contained in:
tianmo committed 2026-10-17 16:19:18 +08:00
1 parent 7b2fae6335
commit 76328fa058
10 files changed
+196 -17

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
detectSystemMemory,
isLowMemory,
LOW_MEMORY_THRESHOLD_BYTES
} from './system-resources'
describe('system resource detection', () => {
it('treats machines below 2 GB as low memory', () => {
expect(isLowMemory(1024 * 1024 * 1024)).toBe(true)
expect(isLowMemory(LOW_MEMORY_THRESHOLD_BYTES - 1)).toBe(true)
})
it('does not flag machines at or above 2 GB', () => {
expect(isLowMemory(LOW_MEMORY_THRESHOLD_BYTES)).toBe(false)
expect(isLowMemory(4 * 1024 * 1024 * 1024)).toBe(false)
})
it('returns usable current memory information', () => {
const memory = detectSystemMemory()
expect(memory.totalBytes).toBeGreaterThan(0)
expect(memory.thresholdBytes).toBe(LOW_MEMORY_THRESHOLD_BYTES)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { totalmem } from 'node:os'
export const LOW_MEMORY_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024
export function isLowMemory(bytes: number): boolean {
return bytes > 0 && bytes < LOW_MEMORY_THRESHOLD_BYTES
}
function readCgroupMemoryLimit(): number | null {
for (const path of [
'/sys/fs/cgroup/memory.max',
'/sys/fs/cgroup/memory/memory.limit_in_bytes'
]) {
try {
const raw = readFileSync(path, 'utf8').trim()
if (!raw || raw === 'max') continue
const value = Number(raw)
if (Number.isFinite(value) && value > 0) return value
} catch {
// The current platform may not use cgroup memory limits.
}
}
return null
}
export function detectSystemMemory(): {
totalBytes: number
lowMemory: boolean
thresholdBytes: number
} {
const hostMemory = totalmem()
const cgroupLimit = readCgroupMemoryLimit()
const totalBytes = cgroupLimit
? Math.min(hostMemory, cgroupLimit)
: hostMemory
return {
totalBytes,
lowMemory: isLowMemory(totalBytes),
thresholdBytes: LOW_MEMORY_THRESHOLD_BYTES
}
}
+14 -1
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
cleanupExpiredHlsDownloads,
buildThumbnailFfmpegArgs,
DEFAULT_TRANSCODE_CONCURRENCY,
DEFAULT_TRANSCODE_PRESET,
DEFAULT_TRANSCODE_THREADS,
@@ -15,7 +16,8 @@ import {
parseTranscodePreset,
setTranscodeSettings,
summarizeTranscodeFailure,
thumbKeyFor
thumbKeyFor,
THUMBNAIL_FFMPEG_TIMEOUT_MS
} from './transcode'
import { isVideoSidecarKey, mediaTypeForKey } from './image-key'
@@ -28,6 +30,17 @@ describe('hlsPlaylistKey', () => {
})
})
describe('thumbnail ffmpeg args', () => {
it('limits thumbnail extraction to one worker and one filter thread', () => {
const args = buildThumbnailFfmpegArgs('/tmp/input.mp4', '/tmp/thumb.png', 1)
expect(args.slice(args.indexOf('-threads'), args.indexOf('-threads') + 2))
.toEqual(['-threads', '1'])
expect(args.slice(args.indexOf('-filter_threads'), args.indexOf('-filter_threads') + 2))
.toEqual(['-filter_threads', '1'])
expect(THUMBNAIL_FFMPEG_TIMEOUT_MS).toBe(10_000)
})
})
describe('isVideoSidecarKey', () => {
it('detects playlists, segments and thumbnails', () => {
expect(isVideoSidecarKey('videos/2026/10/abc/index.m3u8')).toBe(true)
+51 -15
View File
@@ -209,32 +209,67 @@ export function hlsSegmentNames(playlistText: string): string[] {
return [...names]
}
export function buildThumbnailFfmpegArgs(
inputPath: string,
outputPath: string,
seek: number
): string[] {
return [
'-hide_banner',
'-loglevel', 'error',
'-threads', '1',
'-filter_threads', '1',
'-y',
'-ss', String(seek),
'-i', inputPath,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-c:v', 'png',
outputPath
]
}
export const THUMBNAIL_FFMPEG_TIMEOUT_MS = 10_000
class ThumbnailFfmpegTimeoutError extends Error {
constructor() {
super(`缩略图生成超时(${THUMBNAIL_FFMPEG_TIMEOUT_MS / 1000} 秒)`)
this.name = 'ThumbnailFfmpegTimeoutError'
}
}
function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [
'-hide_banner',
'-loglevel', 'error',
'-y',
'-ss', String(seek),
'-i', inputPath,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-c:v', 'png',
outputPath
], { windowsHide: true })
const child = spawn(
'ffmpeg',
buildThumbnailFfmpegArgs(inputPath, outputPath, seek),
{ windowsHide: true }
)
let stderr = ''
let finished = false
const finish = (error?: Error) => {
if (finished) return
finished = true
clearTimeout(timer)
if (error) reject(error)
else resolve()
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(new ThumbnailFfmpegTimeoutError())
}, THUMBNAIL_FFMPEG_TIMEOUT_MS)
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.on('error', reject)
child.on('error', error => finish(error))
child.on('close', (code) => {
if (code === 0) {
resolve()
finish()
return
}
const detail = stderr.trim().split('\n').filter(Boolean).pop()
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
finish(new Error(detail || `ffmpeg 退出码 ${code}`))
})
})
}
@@ -259,7 +294,8 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
try {
await runThumbFfmpeg(inputPath, framePath, 1)
} catch {
} catch (error) {
if (error instanceof ThumbnailFfmpegTimeoutError) throw error
await runThumbFfmpeg(inputPath, framePath, 0)
}