Cancel stale playback probes and verify Linux QSV support

This commit is contained in:
Technomancer702
2026-10-13 19:02:15 -07:00
parent cca55edc4e
commit 379971022d
22 changed files with 443 additions and 158 deletions

View File

@ -9,7 +9,8 @@
* Results are cached at startup and exposed via API.
*/
const { execSync, exec } = require('child_process');
const { execSync, execFile } = require('child_process');
const fs = require('fs');
const os = require('os');
// Cache detection results
@ -118,46 +119,51 @@ async function detectVAAPI() {
* Detect Intel QuickSync support
* Checks if FFmpeg can use QSV
*/
async function detectQuickSync() {
async function detectQuickSync(ffmpegPath = 'ffmpeg') {
try {
// Check if Intel GPU exists
let hasIntelGpu = false;
if (os.platform() === 'win32') {
// Windows: Check via WMIC
const result = execSync(
'wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
hasIntelGpu = result.toLowerCase().includes('intel');
} else if (os.platform() === 'linux') {
// Linux: Check lspci
const result = execSync('wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true });
return result.toLowerCase().includes('intel')
? { available: true, encoder: 'h264_qsv', decoder: 'h264_qsv' }
: { available: false, reason: 'No Intel GPU found' };
}
if (os.platform() !== 'linux') return { available: false, reason: 'Unsupported QSV platform' };
const nodes = fs.readdirSync('/dev/dri').filter(name => /^renderD\d+$/.test(name));
const intel = [], unknown = [];
for (const name of nodes) {
const device = '/dev/dri/' + name;
try { fs.accessSync(device, fs.constants.R_OK | fs.constants.W_OK); } catch { continue; }
try {
const result = execSync('lspci | grep -i "vga\\|display" | grep -i intel', {
timeout: 5000,
encoding: 'utf-8',
shell: true
});
hasIntelGpu = result.trim().length > 0;
} catch {
hasIntelGpu = false;
const vendor = fs.readFileSync('/sys/class/drm/' + name + '/device/vendor', 'utf8').trim().toLowerCase();
if (vendor === '0x8086') intel.push(device);
} catch { unknown.push(device); }
}
// Some containers hide sysfs. Only use the PCI fallback for a single unclassified mapped device.
if (!intel.length && nodes.length === 1 && unknown.length === 1) {
try {
const pci = execSync('lspci', { timeout: 2000, encoding: 'utf8', windowsHide: true });
if (pci.split('\n').some(line => /VGA|display|3D/i.test(line) && /intel/i.test(line))) intel.push(unknown[0]);
} catch { /* No PCI utility or Intel adapter visible. */ }
}
for (const device of intel) {
const usable = await new Promise(resolve => {
execFile(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
'-init_hw_device', 'qsv=probe:hw,child_device=' + device + ',child_device_type=vaapi',
'-filter_hw_device', 'probe', '-f', 'lavfi', '-i', 'color=s=64x64:r=1',
'-vf', 'format=nv12,hwupload=extra_hw_frames=16', '-c:v', 'h264_qsv',
'-frames:v', '1', '-f', 'null', '-'],
{ timeout: 5000, killSignal: 'SIGKILL', windowsHide: true, maxBuffer: 256 * 1024 }, error => resolve(!error));
});
if (usable) {
console.log('[HwDetect] QuickSync encode verified on ' + device);
return { available: true, device, encoder: 'h264_qsv', decoder: 'h264_qsv' };
}
}
if (!hasIntelGpu) {
return { available: false, reason: 'No Intel GPU found' };
}
console.log('[HwDetect] Intel GPU detected, QSV may be available');
return {
available: true,
encoder: 'h264_qsv',
decoder: 'h264_qsv'
};
} catch (err) {
console.log('[HwDetect] QuickSync not available');
return { available: false };
return { available: false, reason: intel.length ? 'QSV encode test failed' : 'No accessible Intel render device found' };
} catch {
return { available: false, reason: 'QuickSync not available' };
}
}
@ -214,7 +220,7 @@ async function detectAMF() {
* Detect all hardware capabilities
* Results are cached for performance
*/
async function detect() {
async function detect(ffmpegPath) {
if (hwCapabilities !== null) {
return hwCapabilities;
}
@ -224,7 +230,7 @@ async function detect() {
const [nvidia, vaapi, qsv, amf] = await Promise.all([
detectNvidia(),
detectVAAPI(),
detectQuickSync(),
detectQuickSync(ffmpegPath),
detectAMF()
]);
@ -265,9 +271,9 @@ function getCapabilities() {
/**
* Force re-detection (clears cache)
*/
async function refresh() {
async function refresh(ffmpegPath) {
hwCapabilities = null;
return detect();
return detect(ffmpegPath);
}
module.exports = {

View File

@ -0,0 +1,56 @@
function cancelled() { return Object.assign(new Error('Playback probe was superseded'), { name: 'AbortError' }); }
function parseProbeLease(query) {
if (query.probeLeaseId === undefined && query.requestSequence === undefined) return null;
if (typeof query.probeLeaseId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(query.probeLeaseId) ||
typeof query.requestSequence !== 'string' || !/^[1-9]\d{0,14}$/.test(query.requestSequence)) {
throw new Error('Invalid probe lease or request sequence');
}
return { id: query.probeLeaseId, sequence: Number(query.requestSequence) };
}
function requestCancellation(req, res) {
const controller = new AbortController();
const abort = () => { if (!res.writableEnded) controller.abort(); };
res.once('close', abort);
if (req.aborted || res.destroyed) controller.abort();
return { signal: controller.signal, dispose: () => res.off('close', abort) };
}
class ProbeLeases {
constructor() { this.entries = new Map(); }
run(ownerId, lease, signal, task) {
if (signal.aborted) return Promise.reject(cancelled());
if (!lease) return task(signal);
const now = Date.now();
for (const [key, entry] of this.entries) {
if (!entry.job && now - entry.touched > 60000) this.entries.delete(key);
}
const key = JSON.stringify([ownerId, lease.id]);
const previous = this.entries.get(key);
if (previous && lease.sequence <= previous.sequence) return Promise.reject(cancelled());
if (!previous && this.entries.size >= 1024) {
const idle = Array.from(this.entries).find(([, entry]) => !entry.job);
if (idle) this.entries.delete(idle[0]);
else return Promise.reject(Object.assign(new Error('Too many active probes'), { code: 'PROBE_LIMIT' }));
}
const controller = new AbortController(), job = { controller };
const entry = { sequence: lease.sequence, touched: now, job };
this.entries.set(key, entry);
const abort = () => controller.abort();
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
previous?.job?.controller.abort();
// Wait for the previous process's close event, including when this request will use cached metadata.
job.done = Promise.resolve().then(async () => {
if (previous?.job) await previous.job.done.catch(() => {});
if (controller.signal.aborted || this.entries.get(key) !== entry) throw cancelled();
return task(controller.signal);
}).finally(() => {
signal.removeEventListener('abort', abort);
if (this.entries.get(key) === entry) { entry.job = null; entry.touched = Date.now(); }
});
return job.done;
}
}
module.exports = { ProbeLeases, parseProbeLease, requestCancellation, cancelled };

View File

@ -331,13 +331,19 @@ class TranscodeSession extends EventEmitter {
'-hwaccel_output_format', 'vaapi'
);
break;
case 'qsv':
case 'qsv': {
const device = hwDetect.getCapabilities()?.qsv?.device;
if (typeof device === 'string' && /^\/dev\/dri\/renderD\d+$/.test(device)) {
args.push('-init_hw_device', 'qsv=nodecast:hw,child_device=' + device + ',child_device_type=vaapi',
'-filter_hw_device', 'nodecast', '-hwaccel_device', 'nodecast');
}
// Intel QuickSync hardware decoding
args.push(
'-hwaccel', 'qsv',
'-hwaccel_output_format', 'qsv'
);
break;
}
case 'amf':
// AMD AMF (no hwaccel input, AMF is encode-only)
// Decode on CPU, encode on GPU