ci / ci (22, ubuntu-latest) (push) Canceled after 0s
文件床 - 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立 - 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg - 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载 视频床 - 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB - 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行 - 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例) - 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩 - 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接 - 上传时自动抽帧生成封面,视频与封面各有独立链接 - 删除视频时中断转码并清理切片、封面与源文件 媒体库 - 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布 - 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆 - 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件) 修复 - 清理无主的切片、封面与开发期遗留的 ffmpeg 进程 - 源文件按设计删除后,索引不再被启动孤儿清理误删 - 视频统计口径与列表一致,转码中的视频不计入统计 - 转码失败时回收已写入的部分切片,避免残留 - 设置页停用检查更新(与上游版本号已不对应) 其他 - Docker 镜像安装 ffmpeg - git remote 指向内网仓库
713 lines
21 KiB
TypeScript
713 lines
21 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import type { ChildProcess } from 'node:child_process'
|
|
import { createWriteStream, promises as fs } from 'node:fs'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, join } from 'node:path'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import { getDataDir } from './data-dir'
|
|
import {
|
|
listAllImageIndexRows,
|
|
listPendingTranscodeKeys,
|
|
listTranscodedSourceRows,
|
|
updateImageTranscode
|
|
} from './image-index'
|
|
import {
|
|
VIDEO_FOLDER,
|
|
extensionFromFilename,
|
|
isVideoSidecarKey,
|
|
validateStorageKey,
|
|
validateVideoKey
|
|
} from './image-key'
|
|
import {
|
|
createImageStream,
|
|
deleteImage,
|
|
headImage
|
|
} from './storage'
|
|
import { getBackendForKey } from './storage/resolver'
|
|
import { logException, logInfo } from './logger'
|
|
import {
|
|
getSetting,
|
|
SETTINGS_TRANSCODE_CONCURRENCY,
|
|
SETTINGS_TRANSCODE_BITRATE,
|
|
SETTINGS_TRANSCODE_PRESET,
|
|
SETTINGS_TRANSCODE_THREADS,
|
|
setSetting
|
|
} from './db'
|
|
|
|
/** HLS 分片时长(秒) */
|
|
const HLS_SEGMENT_TIME = 6
|
|
|
|
export const DEFAULT_TRANSCODE_CONCURRENCY = 1
|
|
export const DEFAULT_TRANSCODE_THREADS = 1
|
|
export const MAX_TRANSCODE_CONCURRENCY = 8
|
|
export const MAX_TRANSCODE_THREADS = 64
|
|
|
|
/**
|
|
* 转码档位:直接封装(不重编码)/ 速度 / 平衡 / 质量。
|
|
* 注意 CRF 模式下 preset 越慢画质越好、体积也越大。
|
|
*/
|
|
export const TRANSCODE_PRESETS = ['copy', 'veryfast', 'medium', 'slow', 'compress'] as const
|
|
export type TranscodePreset = (typeof TRANSCODE_PRESETS)[number]
|
|
export const DEFAULT_TRANSCODE_PRESET: TranscodePreset = 'veryfast'
|
|
|
|
/** 「压缩」档的目标码率占源码率的百分比 */
|
|
export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80
|
|
export const MIN_TRANSCODE_BITRATE_PERCENT = 10
|
|
export const MAX_TRANSCODE_BITRATE_PERCENT = 100
|
|
|
|
export function getTranscodeBitratePercent(): number {
|
|
return parseBoundedInt(
|
|
getSetting(SETTINGS_TRANSCODE_BITRATE),
|
|
DEFAULT_TRANSCODE_BITRATE_PERCENT,
|
|
MIN_TRANSCODE_BITRATE_PERCENT,
|
|
MAX_TRANSCODE_BITRATE_PERCENT
|
|
)
|
|
}
|
|
|
|
export function parseTranscodePreset(raw: string | null | undefined): TranscodePreset | null {
|
|
const value = raw?.trim()
|
|
if (!value) return null
|
|
return (TRANSCODE_PRESETS as readonly string[]).includes(value)
|
|
? value as TranscodePreset
|
|
: null
|
|
}
|
|
|
|
function parseBoundedInt(
|
|
raw: string | null,
|
|
fallback: number,
|
|
min: number,
|
|
max: number
|
|
): number {
|
|
const value = Number(raw)
|
|
if (!Number.isInteger(value) || value < min || value > max) return fallback
|
|
return value
|
|
}
|
|
|
|
export interface TranscodeSettings {
|
|
concurrency: number
|
|
threads: number
|
|
preset: TranscodePreset
|
|
bitratePercent: number
|
|
}
|
|
|
|
/** 同时进行的转码任务数,默认 1(串行) */
|
|
export function getTranscodeConcurrency(): number {
|
|
return parseBoundedInt(
|
|
getSetting(SETTINGS_TRANSCODE_CONCURRENCY),
|
|
DEFAULT_TRANSCODE_CONCURRENCY,
|
|
1,
|
|
MAX_TRANSCODE_CONCURRENCY
|
|
)
|
|
}
|
|
|
|
/** 每个转码任务使用的 CPU 核心数,默认 1 */
|
|
export function getTranscodeThreads(): number {
|
|
return parseBoundedInt(
|
|
getSetting(SETTINGS_TRANSCODE_THREADS),
|
|
DEFAULT_TRANSCODE_THREADS,
|
|
1,
|
|
MAX_TRANSCODE_THREADS
|
|
)
|
|
}
|
|
|
|
export function getTranscodeSettings(): TranscodeSettings {
|
|
return {
|
|
concurrency: getTranscodeConcurrency(),
|
|
threads: getTranscodeThreads(),
|
|
preset: parseTranscodePreset(getSetting(SETTINGS_TRANSCODE_PRESET)) ?? DEFAULT_TRANSCODE_PRESET,
|
|
bitratePercent: getTranscodeBitratePercent()
|
|
}
|
|
}
|
|
|
|
export function setTranscodeSettings(patch: {
|
|
concurrency?: number
|
|
threads?: number
|
|
preset?: TranscodePreset
|
|
bitratePercent?: number
|
|
}): TranscodeSettings {
|
|
if (patch.concurrency !== undefined) {
|
|
setSetting(SETTINGS_TRANSCODE_CONCURRENCY, String(patch.concurrency))
|
|
}
|
|
if (patch.threads !== undefined) {
|
|
setSetting(SETTINGS_TRANSCODE_THREADS, String(patch.threads))
|
|
}
|
|
if (patch.preset !== undefined) {
|
|
setSetting(SETTINGS_TRANSCODE_PRESET, patch.preset)
|
|
}
|
|
if (patch.bitratePercent !== undefined) {
|
|
setSetting(SETTINGS_TRANSCODE_BITRATE, String(patch.bitratePercent))
|
|
}
|
|
return getTranscodeSettings()
|
|
}
|
|
|
|
function stripExtension(key: string): string {
|
|
const slash = key.lastIndexOf('/')
|
|
const dot = key.lastIndexOf('.')
|
|
return dot > slash ? key.slice(0, dot) : key
|
|
}
|
|
|
|
function extensionOf(key: string): string {
|
|
const dot = key.lastIndexOf('.')
|
|
return dot > 0 ? key.slice(dot + 1) : 'mp4'
|
|
}
|
|
|
|
/** 视频 key 对应的 HLS 播放列表 key(去扩展名后作为目录) */
|
|
export function hlsPlaylistKey(videoKey: string): string {
|
|
return `${stripExtension(videoKey)}/index.m3u8`
|
|
}
|
|
|
|
/** 视频 key 对应的封面缩略图 key(同目录同名,扩展名换成 webp) */
|
|
export function thumbKeyFor(videoKey: string): string {
|
|
return `${stripExtension(videoKey)}.webp`
|
|
}
|
|
|
|
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', 'libwebp',
|
|
'-quality', '80',
|
|
outputPath
|
|
], { windowsHide: true })
|
|
|
|
let stderr = ''
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += String(chunk)
|
|
})
|
|
child.on('error', reject)
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve()
|
|
return
|
|
}
|
|
const detail = stderr.trim().split('\n').filter(Boolean).pop()
|
|
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 抽取视频封面并写入存储,返回缩略图 key。
|
|
* 优先取第 1 秒的画面,短视频或抽取失败时退回第一帧。
|
|
*/
|
|
export async function generateVideoThumbnail(videoKey: string): Promise<string | null> {
|
|
const stored = await headImage(videoKey)
|
|
if (!stored) return null
|
|
|
|
const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`)
|
|
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
|
|
const outputPath = join(workDir, 'thumb.webp')
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
|
|
try {
|
|
await streamToFile(await createImageStream(videoKey), inputPath)
|
|
|
|
try {
|
|
await runThumbFfmpeg(inputPath, outputPath, 1)
|
|
} catch {
|
|
await runThumbFfmpeg(inputPath, outputPath, 0)
|
|
}
|
|
|
|
const bytes = await fs.readFile(outputPath)
|
|
const thumbKey = thumbKeyFor(videoKey)
|
|
const backend = await getBackendForKey(videoKey)
|
|
|
|
await backend.put(thumbKey, new Uint8Array(bytes), {
|
|
originalName: `${basename(thumbKey)}`,
|
|
uploadedAt: new Date().toISOString(),
|
|
contentType: 'image/webp',
|
|
size: bytes.byteLength,
|
|
userId: stored.userId ?? null,
|
|
mediaType: 'video'
|
|
})
|
|
|
|
return thumbKey
|
|
} catch (error) {
|
|
logException('video thumbnail failed', error, { key: videoKey })
|
|
return null
|
|
} finally {
|
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
/** 正在转码的视频 → ffmpeg 进程,用于删除时中断 */
|
|
const runningProcesses = new Map<string, ChildProcess>()
|
|
|
|
/** 读取视频总时长(秒);失败返回 0,表示无法计算进度 */
|
|
function probeDuration(inputPath: string): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn('ffprobe', [
|
|
'-v', 'error',
|
|
'-show_entries', 'format=duration',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
inputPath
|
|
], { windowsHide: true })
|
|
|
|
let output = ''
|
|
child.stdout.on('data', (chunk) => {
|
|
output += String(chunk)
|
|
})
|
|
child.on('error', () => resolve(0))
|
|
child.on('close', () => {
|
|
const value = Number.parseFloat(output.trim())
|
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
|
})
|
|
})
|
|
}
|
|
|
|
/** 读取视频总码率(bps);失败返回 0 */
|
|
function probeBitrate(inputPath: string): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn('ffprobe', [
|
|
'-v', 'error',
|
|
'-show_entries', 'format=bit_rate',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
inputPath
|
|
], { windowsHide: true })
|
|
|
|
let output = ''
|
|
child.stdout.on('data', (chunk) => {
|
|
output += String(chunk)
|
|
})
|
|
child.on('error', () => resolve(0))
|
|
child.on('close', () => {
|
|
const value = Number.parseInt(output.trim(), 10)
|
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 读取音轨码率(bps);没有音轨或读不到时返回 0。
|
|
* 音频目标码率会取「128k 与源音频码率」中的较小值,避免低码率音频被放大。
|
|
*/
|
|
function probeAudioBitrate(inputPath: string): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn('ffprobe', [
|
|
'-v', 'error',
|
|
'-select_streams', 'a:0',
|
|
'-show_entries', 'stream=bit_rate',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
inputPath
|
|
], { windowsHide: true })
|
|
|
|
let output = ''
|
|
child.stdout.on('data', (chunk) => {
|
|
output += String(chunk)
|
|
})
|
|
child.on('error', () => resolve(0))
|
|
child.on('close', () => {
|
|
const value = Number.parseInt(output.trim(), 10)
|
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
|
})
|
|
})
|
|
}
|
|
|
|
/** 单码率 H.264 + AAC 切片,产出 index.m3u8 与 segNNN.ts */
|
|
function runFfmpeg(
|
|
videoKey: string,
|
|
inputPath: string,
|
|
outputDir: string,
|
|
sourceBitrate: number,
|
|
sourceAudioBitrate: number,
|
|
onProgressUs: (outTimeUs: number) => void
|
|
): Promise<void> {
|
|
const { preset, bitratePercent } = getTranscodeSettings()
|
|
|
|
// 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k)
|
|
const audioBitrate = sourceAudioBitrate > 0
|
|
? sourceAudioBitrate
|
|
: 128_000
|
|
const audioKbps = `${Math.round(audioBitrate / 1000)}k`
|
|
|
|
let codecArgs: string[]
|
|
if (preset === 'copy') {
|
|
// 不重新编码:只把音视频流重新封装成 HLS 切片
|
|
codecArgs = ['-c', 'copy', '-bsf:v', 'h264_mp4toannexb']
|
|
} else if (preset === 'compress') {
|
|
// 目标「总码率」= 源码率 × 比例,再扣掉音频预算得到视频码率,避免总量反超
|
|
const targetTotal = Math.round(sourceBitrate * bitratePercent / 100)
|
|
const targetVideo = Math.max(80_000, targetTotal - audioBitrate)
|
|
codecArgs = [
|
|
'-c:v', 'libx264',
|
|
'-preset', 'medium',
|
|
'-b:v', String(targetVideo),
|
|
'-maxrate', String(Math.round(targetVideo * 1.2)),
|
|
'-bufsize', String(targetVideo * 2),
|
|
'-c:a', 'aac',
|
|
'-b:a', audioKbps
|
|
]
|
|
} else {
|
|
// 恒定质量优先,但用「源码率 - 音频预算」卡住视频峰值,避免体积反超源文件
|
|
const ceiling = sourceBitrate > audioBitrate ? sourceBitrate - audioBitrate : 0
|
|
codecArgs = [
|
|
'-c:v', 'libx264',
|
|
'-preset', preset,
|
|
'-crf', '23',
|
|
...(ceiling
|
|
? ['-maxrate', String(ceiling), '-bufsize', String(ceiling * 2)]
|
|
: []),
|
|
'-c:a', 'aac',
|
|
'-b:a', audioKbps
|
|
]
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('ffmpeg', [
|
|
'-hide_banner',
|
|
'-loglevel', 'error',
|
|
'-nostats',
|
|
'-progress', 'pipe:1',
|
|
'-y',
|
|
'-i', inputPath,
|
|
'-threads', String(getTranscodeThreads()),
|
|
...codecArgs,
|
|
'-f', 'hls',
|
|
'-hls_time', String(HLS_SEGMENT_TIME),
|
|
'-hls_playlist_type', 'vod',
|
|
// 分片写相对名,避免播放列表里出现本机绝对路径
|
|
'-hls_segment_filename', 'seg%03d.ts',
|
|
'index.m3u8'
|
|
], { cwd: outputDir, windowsHide: true })
|
|
|
|
runningProcesses.set(videoKey, child)
|
|
const release = () => {
|
|
runningProcesses.delete(videoKey)
|
|
}
|
|
|
|
let stderr = ''
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += String(chunk)
|
|
})
|
|
child.stdout.on('data', (chunk) => {
|
|
const match = /out_time_us=(\d+)/.exec(String(chunk))
|
|
if (match?.[1]) onProgressUs(Number(match[1]))
|
|
})
|
|
child.on('error', (error) => {
|
|
release()
|
|
reject(error)
|
|
})
|
|
child.on('close', (code) => {
|
|
release()
|
|
if (code === 0) {
|
|
resolve()
|
|
return
|
|
}
|
|
const detail = stderr.trim().split('\n').filter(Boolean).pop()
|
|
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function streamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise<void> {
|
|
await pipeline(stream, createWriteStream(filePath))
|
|
}
|
|
|
|
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of stream) {
|
|
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
}
|
|
return Buffer.concat(chunks).toString('utf8')
|
|
}
|
|
|
|
/** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */
|
|
export async function transcodeVideoToHls(
|
|
videoKey: string
|
|
): Promise<{ playlistKey: string, totalBytes: number }> {
|
|
const stored = await headImage(videoKey)
|
|
if (!stored) throw new Error('视频不存在')
|
|
|
|
const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`)
|
|
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
|
|
const outputDir = join(workDir, 'out')
|
|
const dirKey = stripExtension(videoKey)
|
|
const playlistKey = hlsPlaylistKey(videoKey)
|
|
|
|
await fs.mkdir(outputDir, { recursive: true })
|
|
|
|
// 记录已写入存储的产物,中途失败时要逐个回收,避免留下残缺切片
|
|
const writtenKeys: string[] = []
|
|
const backend = await getBackendForKey(videoKey)
|
|
|
|
try {
|
|
await streamToFile(await createImageStream(videoKey), inputPath)
|
|
const durationSec = await probeDuration(inputPath)
|
|
const sourceBitrate = await probeBitrate(inputPath)
|
|
const sourceAudioBitrate = await probeAudioBitrate(inputPath)
|
|
let lastProgressAt = 0
|
|
|
|
await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => {
|
|
if (!durationSec) return
|
|
const now = Date.now()
|
|
if (now - lastProgressAt < 1000) return
|
|
lastProgressAt = now
|
|
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
|
|
updateImageTranscode(videoKey, {
|
|
status: 'running',
|
|
progress: Math.min(99, Math.max(1, percent))
|
|
})
|
|
})
|
|
|
|
const uploadedAt = new Date().toISOString()
|
|
|
|
let totalBytes = 0
|
|
for (const name of await fs.readdir(outputDir)) {
|
|
const bytes = await fs.readFile(join(outputDir, name))
|
|
const objectKey = `${dirKey}/${name}`
|
|
await backend.put(objectKey, new Uint8Array(bytes), {
|
|
originalName: name,
|
|
uploadedAt,
|
|
contentType: name.endsWith('.m3u8')
|
|
? 'application/vnd.apple.mpegurl'
|
|
: 'video/mp2t',
|
|
size: bytes.byteLength,
|
|
userId: stored.userId ?? null,
|
|
mediaType: 'video'
|
|
})
|
|
writtenKeys.push(objectKey)
|
|
totalBytes += bytes.byteLength
|
|
}
|
|
|
|
return { playlistKey, totalBytes }
|
|
} catch (error) {
|
|
for (const key of writtenKeys) {
|
|
await backend.delete(key).catch(() => {})
|
|
}
|
|
if (backend.type === 'local') {
|
|
await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {})
|
|
}
|
|
throw error
|
|
} finally {
|
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
/** 删除视频对应的 HLS 播放列表与分片 */
|
|
export async function removeHlsArtifacts(videoKey: string): Promise<void> {
|
|
const playlistKey = hlsPlaylistKey(videoKey)
|
|
|
|
// 封面缩略图与播放列表无关,必须先于提前返回处理
|
|
await deleteImage(thumbKeyFor(videoKey)).catch(() => {})
|
|
|
|
if (!await headImage(playlistKey)) return
|
|
|
|
const dirKey = stripExtension(videoKey)
|
|
const text = await streamToString(await createImageStream(playlistKey))
|
|
|
|
for (const line of text.split('\n')) {
|
|
const name = line.trim()
|
|
if (!name || name.startsWith('#')) continue
|
|
const segmentKey = `${dirKey}/${basename(name)}`
|
|
if (!validateStorageKey(segmentKey)) continue
|
|
await deleteImage(segmentKey).catch(() => {})
|
|
}
|
|
|
|
await deleteImage(playlistKey).catch(() => {})
|
|
|
|
// 本地磁盘上把清空后的分片目录一并移除
|
|
const backend = await getBackendForKey(videoKey)
|
|
if (backend.type === 'local') {
|
|
await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {})
|
|
}
|
|
}
|
|
|
|
const queue: string[] = []
|
|
const queued = new Set<string>()
|
|
let activeCount = 0
|
|
|
|
async function processOne(videoKey: string): Promise<void> {
|
|
updateImageTranscode(videoKey, { status: 'running' })
|
|
|
|
try {
|
|
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey)
|
|
|
|
// 转码成功后源文件不再需要,直接删除以节省空间
|
|
const backend = await getBackendForKey(videoKey)
|
|
await backend.delete(videoKey).catch(() => {})
|
|
|
|
updateImageTranscode(videoKey, {
|
|
status: 'done',
|
|
hlsKey: playlistKey,
|
|
progress: 100,
|
|
sourceDeleted: true,
|
|
size: totalBytes
|
|
})
|
|
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
|
|
} catch (error) {
|
|
updateImageTranscode(videoKey, { status: 'failed' })
|
|
logException('transcode failed', error, { key: videoKey })
|
|
}
|
|
}
|
|
|
|
/** 按设置的并发数消费队列(默认 1,即串行) */
|
|
function drain(): void {
|
|
while (queue.length && activeCount < getTranscodeConcurrency()) {
|
|
const key = queue.shift()!
|
|
activeCount += 1
|
|
|
|
void processOne(key).finally(() => {
|
|
activeCount -= 1
|
|
queued.delete(key)
|
|
drain()
|
|
})
|
|
}
|
|
}
|
|
|
|
export function enqueueTranscode(videoKey: string): void {
|
|
if (!validateVideoKey(videoKey) || queued.has(videoKey)) return
|
|
queued.add(videoKey)
|
|
queue.push(videoKey)
|
|
// 记录本视频使用的档位,便于上传页展示
|
|
const preset = getTranscodeSettings().preset
|
|
updateImageTranscode(videoKey, {
|
|
status: 'pending',
|
|
// 压缩档带上百分比,例如 compress:80
|
|
preset: preset === 'compress'
|
|
? `compress:${getTranscodeBitratePercent()}`
|
|
: preset
|
|
})
|
|
drain()
|
|
}
|
|
|
|
/** 取消排队中或正在转码的任务(删除视频时调用) */
|
|
export function cancelTranscode(videoKey: string): void {
|
|
const index = queue.indexOf(videoKey)
|
|
if (index >= 0) queue.splice(index, 1)
|
|
queued.delete(videoKey)
|
|
|
|
const child = runningProcesses.get(videoKey)
|
|
if (child) {
|
|
runningProcesses.delete(videoKey)
|
|
child.kill()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 补删转码完成后仍保留的源文件。
|
|
* 仅当 HLS 播放列表确实存在时才删除,避免产物缺失时把视频删没。
|
|
*/
|
|
export async function cleanupTranscodedSources(): Promise<number> {
|
|
let removed = 0
|
|
|
|
for (const row of listTranscodedSourceRows()) {
|
|
try {
|
|
if (!await headImage(row.hlsKey)) continue
|
|
|
|
const backend = await getBackendForKey(row.key)
|
|
await backend.delete(row.key).catch(() => {})
|
|
updateImageTranscode(row.key, { status: 'done', sourceDeleted: true })
|
|
removed += 1
|
|
} catch (error) {
|
|
logException('cleanup transcoded source failed', error, { key: row.key })
|
|
}
|
|
}
|
|
|
|
return removed
|
|
}
|
|
|
|
/** 启动时把上次中断的任务重新排队 */
|
|
export function resumePendingTranscodes(): void {
|
|
for (const key of listPendingTranscodeKeys()) {
|
|
enqueueTranscode(key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理遗留的转码进程。
|
|
* 开发时热重载会重建模块,队列里的旧 ffmpeg 仍在跑,必须按命令行特征回收。
|
|
*/
|
|
export function killOrphanFfmpeg(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const psScript = 'Get-CimInstance Win32_Process -Filter "Name=\'ffmpeg.exe\'" '
|
|
+ '| Where-Object { $_.CommandLine -like \'*pichost-hls-*\' } '
|
|
+ '| ForEach-Object { Stop-Process -Id $_.ProcessId -Force }'
|
|
|
|
const child = process.platform === 'win32'
|
|
? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], {
|
|
windowsHide: true,
|
|
stdio: 'ignore'
|
|
})
|
|
: spawn('pkill', ['-f', 'pichost-hls-'], { stdio: 'ignore' })
|
|
|
|
child.on('error', () => resolve())
|
|
child.on('close', () => resolve())
|
|
} catch {
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 清理没有对应主视频记录的附属文件(缩略图、HLS 播放列表与分片)。
|
|
* 正常删除流程已经清理,这里兜底历史残留。
|
|
*/
|
|
export async function purgeOrphanVideoAssets(): Promise<number> {
|
|
const root = join(getDataDir(), VIDEO_FOLDER)
|
|
const owners = new Set(
|
|
listAllImageIndexRows()
|
|
.filter(row => row.key.startsWith(`${VIDEO_FOLDER}/`))
|
|
.map(row => stripExtension(row.key))
|
|
)
|
|
|
|
let removed = 0
|
|
|
|
async function walk(dir: string, keyPrefix: string): Promise<void> {
|
|
let entries
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const name = String(entry.name)
|
|
const key = `${keyPrefix}/${name}`
|
|
|
|
if (entry.isDirectory()) {
|
|
await walk(join(dir, name), key)
|
|
// 附属文件清空后的空目录一并移除
|
|
await fs.rmdir(join(dir, name)).catch(() => {})
|
|
continue
|
|
}
|
|
|
|
if (!isVideoSidecarKey(key)) continue
|
|
|
|
const extension = extensionFromFilename(name)
|
|
// 缩略图与视频同名前缀;播放列表和分片归属所在目录
|
|
const owner = ['webp', 'jpg', 'jpeg', 'png'].includes(extension)
|
|
? stripExtension(key)
|
|
: keyPrefix
|
|
|
|
if (owners.has(owner)) continue
|
|
|
|
await fs.rm(join(dir, name), { force: true }).catch(() => {})
|
|
removed += 1
|
|
}
|
|
}
|
|
|
|
await walk(root, VIDEO_FOLDER)
|
|
return removed
|
|
}
|
|
|
|
/** 进程退出时尽量带走正在运行的 ffmpeg,避免留下孤儿进程 */
|
|
function killRunningProcesses(): void {
|
|
for (const child of runningProcesses.values()) {
|
|
child.kill()
|
|
}
|
|
runningProcesses.clear()
|
|
}
|
|
|
|
process.on('exit', killRunningProcesses)
|
|
process.on('SIGINT', killRunningProcesses)
|
|
process.on('SIGTERM', killRunningProcesses)
|