feat: add thumbnail timeout and low-memory warning
This commit is contained in:
1 parent
7b2fae6335
commit
76328fa058
10 files changed
+196
-17
No files matched your search
@@ -31,6 +31,11 @@ const gridClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
lowMemory,
|
||||||
|
memoryBytes,
|
||||||
|
load: loadTranscodeSettings
|
||||||
|
} = useTranscodeSettings()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSettings,
|
userSettings,
|
||||||
@@ -52,6 +57,12 @@ const clientQualityLevel = computed(() => {
|
|||||||
return t('preferences.qualityLow')
|
return t('preferences.qualityLow')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const memoryGb = computed(() => (
|
||||||
|
memoryBytes.value > 0
|
||||||
|
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
|
||||||
|
: '—'
|
||||||
|
))
|
||||||
|
|
||||||
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
||||||
copyFormat.value = value
|
copyFormat.value = value
|
||||||
}
|
}
|
||||||
@@ -64,6 +75,7 @@ function onClientQualityInput(event: Event) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
|
void loadTranscodeSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -170,6 +182,15 @@ onMounted(() => {
|
|||||||
{{ t('preferences.videoProcessing') }}
|
{{ t('preferences.videoProcessing') }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="lowMemory"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="t('preferences.lowMemoryTranscodeTitle')"
|
||||||
|
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
|
||||||
|
/>
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-start gap-2.5">
|
<label class="flex cursor-pointer items-start gap-2.5">
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
v-model="transcodeToHls"
|
v-model="transcodeToHls"
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { transcodeToHls, loadPreferences } = useUploadPreferences('video')
|
const { transcodeToHls, loadPreferences } = useUploadPreferences('video')
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
lowMemory,
|
||||||
|
memoryBytes,
|
||||||
|
load: loadTranscodeSettings
|
||||||
|
} = useTranscodeSettings()
|
||||||
|
|
||||||
|
const memoryGb = computed(() => (
|
||||||
|
memoryBytes.value > 0
|
||||||
|
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
|
||||||
|
: '—'
|
||||||
|
))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
|
void loadTranscodeSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<UAlert
|
||||||
|
v-if="lowMemory"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="t('preferences.lowMemoryTranscodeTitle')"
|
||||||
|
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
|
||||||
|
class="my-4"
|
||||||
|
/>
|
||||||
<SettingsToggleRow
|
<SettingsToggleRow
|
||||||
v-model="transcodeToHls"
|
v-model="transcodeToHls"
|
||||||
:title="t('preferences.transcodeToHls')"
|
:title="t('preferences.transcodeToHls')"
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface TranscodeSettingsResponse {
|
|||||||
minBitratePercent: number
|
minBitratePercent: number
|
||||||
maxBitratePercent: number
|
maxBitratePercent: number
|
||||||
presets: string[]
|
presets: string[]
|
||||||
|
memoryBytes: number
|
||||||
|
lowMemoryThresholdBytes: number
|
||||||
|
lowMemory: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 服务端转码参数:并发任务数与单任务 CPU 核心数(仅管理员可改) */
|
/** 服务端转码参数:并发任务数与单任务 CPU 核心数(仅管理员可改) */
|
||||||
@@ -27,6 +30,9 @@ export function useTranscodeSettings() {
|
|||||||
const maxBitratePercent = ref(100)
|
const maxBitratePercent = ref(100)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const hydrated = ref(false)
|
const hydrated = ref(false)
|
||||||
|
const memoryBytes = ref(0)
|
||||||
|
const lowMemoryThresholdBytes = ref(2 * 1024 * 1024 * 1024)
|
||||||
|
const lowMemory = ref(false)
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -41,6 +47,10 @@ export function useTranscodeSettings() {
|
|||||||
if (data.presets?.length) presets.value = data.presets
|
if (data.presets?.length) presets.value = data.presets
|
||||||
maxConcurrency.value = data.maxConcurrency
|
maxConcurrency.value = data.maxConcurrency
|
||||||
maxThreads.value = data.maxThreads
|
maxThreads.value = data.maxThreads
|
||||||
|
memoryBytes.value = data.memoryBytes ?? 0
|
||||||
|
lowMemoryThresholdBytes.value = data.lowMemoryThresholdBytes
|
||||||
|
?? 2 * 1024 * 1024 * 1024
|
||||||
|
lowMemory.value = data.lowMemory === true
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
hydrated.value = true
|
hydrated.value = true
|
||||||
@@ -108,6 +118,9 @@ export function useTranscodeSettings() {
|
|||||||
presets,
|
presets,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
maxThreads,
|
maxThreads,
|
||||||
|
memoryBytes,
|
||||||
|
lowMemoryThresholdBytes,
|
||||||
|
lowMemory,
|
||||||
loaded,
|
loaded,
|
||||||
load
|
load
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,6 +308,8 @@
|
|||||||
"qualityLow": "Lossy",
|
"qualityLow": "Lossy",
|
||||||
"convenience": "Convenience",
|
"convenience": "Convenience",
|
||||||
"videoProcessing": "Video processing",
|
"videoProcessing": "Video processing",
|
||||||
|
"lowMemoryTranscodeTitle": "Low-memory server warning",
|
||||||
|
"lowMemoryTranscodeHint": "This machine has about {memory} GB of memory, below 2 GB. HLS transcoding is not recommended for now because it may exhaust memory or make the service unstable.",
|
||||||
"transcodeToHls": "Transcode to m3u8",
|
"transcodeToHls": "Transcode to m3u8",
|
||||||
"transcodeToHlsHint": "Segment into HLS, uses extra CPU.",
|
"transcodeToHlsHint": "Segment into HLS, uses extra CPU.",
|
||||||
"concurrentHint": "at a time",
|
"concurrentHint": "at a time",
|
||||||
|
|||||||
@@ -308,6 +308,8 @@
|
|||||||
"qualityLow": "有损压缩",
|
"qualityLow": "有损压缩",
|
||||||
"convenience": "便捷功能",
|
"convenience": "便捷功能",
|
||||||
"videoProcessing": "视频处理",
|
"videoProcessing": "视频处理",
|
||||||
|
"lowMemoryTranscodeTitle": "低内存服务器提示",
|
||||||
|
"lowMemoryTranscodeHint": "检测到当前机器内存约 {memory} GB,低于 2 GB。暂不建议开启 HLS 转码,以免转码过程导致内存不足或服务不稳定。",
|
||||||
"transcodeToHls": "转码成 m3u8",
|
"transcodeToHls": "转码成 m3u8",
|
||||||
"transcodeToHlsHint": "自动切片为 HLS,会占用 CPU。",
|
"transcodeToHlsHint": "自动切片为 HLS,会占用 CPU。",
|
||||||
"concurrentHint": "同时转码数",
|
"concurrentHint": "同时转码数",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { requireUserAuth } from '../../utils/access'
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import { detectSystemMemory } from '../../utils/system-resources'
|
||||||
import {
|
import {
|
||||||
getTranscodeSettings,
|
getTranscodeSettings,
|
||||||
MAX_TRANSCODE_CONCURRENCY,
|
MAX_TRANSCODE_CONCURRENCY,
|
||||||
@@ -11,12 +12,16 @@ import {
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireUserAuth(event)
|
await requireUserAuth(event)
|
||||||
|
|
||||||
|
const memory = detectSystemMemory()
|
||||||
return {
|
return {
|
||||||
...getTranscodeSettings(),
|
...getTranscodeSettings(),
|
||||||
maxConcurrency: MAX_TRANSCODE_CONCURRENCY,
|
maxConcurrency: MAX_TRANSCODE_CONCURRENCY,
|
||||||
maxThreads: MAX_TRANSCODE_THREADS,
|
maxThreads: MAX_TRANSCODE_THREADS,
|
||||||
minBitratePercent: MIN_TRANSCODE_BITRATE_PERCENT,
|
minBitratePercent: MIN_TRANSCODE_BITRATE_PERCENT,
|
||||||
maxBitratePercent: MAX_TRANSCODE_BITRATE_PERCENT,
|
maxBitratePercent: MAX_TRANSCODE_BITRATE_PERCENT,
|
||||||
presets: TRANSCODE_PRESETS
|
presets: TRANSCODE_PRESETS,
|
||||||
|
memoryBytes: memory.totalBytes,
|
||||||
|
lowMemoryThresholdBytes: memory.thresholdBytes,
|
||||||
|
lowMemory: memory.lowMemory
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
cleanupExpiredHlsDownloads,
|
cleanupExpiredHlsDownloads,
|
||||||
|
buildThumbnailFfmpegArgs,
|
||||||
DEFAULT_TRANSCODE_CONCURRENCY,
|
DEFAULT_TRANSCODE_CONCURRENCY,
|
||||||
DEFAULT_TRANSCODE_PRESET,
|
DEFAULT_TRANSCODE_PRESET,
|
||||||
DEFAULT_TRANSCODE_THREADS,
|
DEFAULT_TRANSCODE_THREADS,
|
||||||
@@ -15,7 +16,8 @@ import {
|
|||||||
parseTranscodePreset,
|
parseTranscodePreset,
|
||||||
setTranscodeSettings,
|
setTranscodeSettings,
|
||||||
summarizeTranscodeFailure,
|
summarizeTranscodeFailure,
|
||||||
thumbKeyFor
|
thumbKeyFor,
|
||||||
|
THUMBNAIL_FFMPEG_TIMEOUT_MS
|
||||||
} from './transcode'
|
} from './transcode'
|
||||||
import { isVideoSidecarKey, mediaTypeForKey } from './image-key'
|
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', () => {
|
describe('isVideoSidecarKey', () => {
|
||||||
it('detects playlists, segments and thumbnails', () => {
|
it('detects playlists, segments and thumbnails', () => {
|
||||||
expect(isVideoSidecarKey('videos/2026/10/abc/index.m3u8')).toBe(true)
|
expect(isVideoSidecarKey('videos/2026/10/abc/index.m3u8')).toBe(true)
|
||||||
|
|||||||
+51
-15
@@ -209,32 +209,67 @@ export function hlsSegmentNames(playlistText: string): string[] {
|
|||||||
return [...names]
|
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> {
|
function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn('ffmpeg', [
|
const child = spawn(
|
||||||
'-hide_banner',
|
'ffmpeg',
|
||||||
'-loglevel', 'error',
|
buildThumbnailFfmpegArgs(inputPath, outputPath, seek),
|
||||||
'-y',
|
{ windowsHide: true }
|
||||||
'-ss', String(seek),
|
)
|
||||||
'-i', inputPath,
|
|
||||||
'-frames:v', '1',
|
|
||||||
'-vf', 'scale=640:-2',
|
|
||||||
'-c:v', 'png',
|
|
||||||
outputPath
|
|
||||||
], { windowsHide: true })
|
|
||||||
|
|
||||||
let stderr = ''
|
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) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
stderr += String(chunk)
|
stderr += String(chunk)
|
||||||
})
|
})
|
||||||
child.on('error', reject)
|
child.on('error', error => finish(error))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const detail = stderr.trim().split('\n').filter(Boolean).pop()
|
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 {
|
try {
|
||||||
await runThumbFfmpeg(inputPath, framePath, 1)
|
await runThumbFfmpeg(inputPath, framePath, 1)
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof ThumbnailFfmpegTimeoutError) throw error
|
||||||
await runThumbFfmpeg(inputPath, framePath, 0)
|
await runThumbFfmpeg(inputPath, framePath, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user