Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion electron/dependencyHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron';
import { logger } from './logger';
import { configManager } from './configManager';
import { detectCudaSupport, pollGpuStats } from './utils';
import { detectCudaSupport, enumerateGpus, pollGpuStats } from './utils';
import { createIpcHandler } from './ipcUtilities';
import { DependencyManager } from './dependencyManager';
import { PluginInstaller } from './pluginInstaller';
Expand Down Expand Up @@ -37,6 +37,10 @@ export function registerDependencyHandlers(
return await pollGpuStats();
});

ipcMain.handle('enumerate-gpus', async () => {
return await enumerateGpus();
});

ipcMain.handle('setup-dependencies',
createIpcHandler(
'setup-dependencies',
Expand Down
18 changes: 10 additions & 8 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
checkDependencies: () => ipcRenderer.invoke('check-dependencies'),
detectCudaSupport: () => ipcRenderer.invoke('detect-cuda-support'),
getGpuStats: () => ipcRenderer.invoke('get-gpu-stats'),
enumerateGpus: () => ipcRenderer.invoke('enumerate-gpus'),
setupDependencies: () => ipcRenderer.invoke('setup-dependencies'),
onSetupProgress: (callback: (progress: any) => void) => {
const listener = (event: any, progress: any) => callback(progress);
Expand All @@ -26,8 +27,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
readVideoFile: (filePath: string) => ipcRenderer.invoke('read-video-file', filePath),
getVideoThumbnail: (filePath: string) => ipcRenderer.invoke('get-video-thumbnail', filePath),
getVideoFrameAt: (filePath: string, frameNumber: number, fps: number) => ipcRenderer.invoke('get-video-frame-at', filePath, frameNumber, fps),
getOutputResolution: (videoPath: string, modelPath: string | null, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, upscalePosition?: number, numStreams?: number, sourceFps?: number) =>
ipcRenderer.invoke('get-output-resolution', videoPath, modelPath, useDirectML, upscalingEnabled, filters, upscalePosition, numStreams, sourceFps),
getOutputResolution: (videoPath: string, modelPath: string | null, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, upscalePosition?: number, numStreams?: number, sourceFps?: number, deviceId?: number) =>
ipcRenderer.invoke('get-output-resolution', videoPath, modelPath, useDirectML, upscalingEnabled, filters, upscalePosition, numStreams, sourceFps, deviceId),
cancelValidation: () => ipcRenderer.invoke('cancel-validation'),
getFilePathFromFile: (file: File) => webUtils.getPathForFile(file),

Expand Down Expand Up @@ -61,10 +62,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Upscaling operations
selectOutputFile: (defaultName: string) => ipcRenderer.invoke('select-output-file', defaultName),
selectFolder: () => ipcRenderer.invoke('select-folder'),
startUpscale: (videoPath: string, modelPath: string, outputPath: string, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, upscalePosition?: number, numStreams?: number, segment?: any, benchmarkMode?: boolean) =>
ipcRenderer.invoke('start-upscale', videoPath, modelPath, outputPath, useDirectML, upscalingEnabled, filters, upscalePosition, numStreams, segment, benchmarkMode),
previewSegment: (videoPath: string, modelPath: string | null, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, numStreams?: number, startFrame?: number, endFrame?: number) =>
ipcRenderer.invoke('preview-segment', videoPath, modelPath, useDirectML, upscalingEnabled, filters, numStreams, startFrame, endFrame),
startUpscale: (videoPath: string, modelPath: string, outputPath: string, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, upscalePosition?: number, numStreams?: number, segment?: any, benchmarkMode?: boolean, deviceId?: number) =>
ipcRenderer.invoke('start-upscale', videoPath, modelPath, outputPath, useDirectML, upscalingEnabled, filters, upscalePosition, numStreams, segment, benchmarkMode, deviceId),
previewSegment: (videoPath: string, modelPath: string | null, useDirectML?: boolean, upscalingEnabled?: boolean, filters?: any, numStreams?: number, startFrame?: number, endFrame?: number, deviceId?: number) =>
ipcRenderer.invoke('preview-segment', videoPath, modelPath, useDirectML, upscalingEnabled, filters, numStreams, startFrame, endFrame, deviceId),
cancelUpscale: () => ipcRenderer.invoke('cancel-upscale'),
killUpscale: () => ipcRenderer.invoke('kill-upscale'),
onUpscaleProgress: (callback: (progress: any) => void) => {
Expand All @@ -81,8 +82,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
upscalingEnabled?: boolean,
filters?: any[],
numStreams?: number,
segment?: { enabled: boolean; startFrame: number; endFrame: number }
) => ipcRenderer.invoke('launch-vse-previewer', videoPath, modelPath, useDirectML, upscalingEnabled, filters, numStreams, segment),
segment?: { enabled: boolean; startFrame: number; endFrame: number },
deviceId?: number
) => ipcRenderer.invoke('launch-vse-previewer', videoPath, modelPath, useDirectML, upscalingEnabled, filters, numStreams, segment, deviceId),

// Shell operations
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
Expand Down
33 changes: 28 additions & 5 deletions electron/scriptGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as os from 'os';
import { PATHS } from './constants';
import { configManager } from './configManager';
import { logger } from './logger';
import { getCachedGpus, enumerateGpus } from './utils';

export type ModelType = 'vsr' | 'image';

Expand Down Expand Up @@ -49,6 +50,7 @@ export interface ScriptConfig {
validationMode?: boolean; // If true, only process first 5 seconds for validation
sourceFps?: number; // Source video FPS for validation frame calculation
generatePreviewOutputs?: boolean; // If true, add output nodes after each filter for vs-view
deviceId?: number;
}

export class VapourSynthScriptGenerator {
Expand All @@ -70,6 +72,27 @@ export class VapourSynthScriptGenerator {
const defaultTransfer = config.colorimetry?.defaultTransfer || '709';
const outputFormat = config.outputFormat || 'vs.YUV420P8';

// Translate OS-level device index for the active backend
// DirectML uses DXGI indexing (matches systeminformation order).
// TensorRT uses CUDA indexing (NVIDIA-only, 0-based).
let finalDeviceId = config.deviceId ?? 0;
if (!config.useDirectML) {
let gpus = getCachedGpus();
if (gpus.length === 0) {
gpus = await enumerateGpus();
}
let cudaIdx = 0;
for (const gpu of gpus) {
if (gpu.index === finalDeviceId) {
finalDeviceId = cudaIdx;
break;
}
if (gpu.vendor === 'nvidia') {
cudaIdx++;
}
}
}

// Process filters sequentially
const filters = config.filters || [];
const enabledFilters = filters.filter(f => f.enabled).sort((a, b) => a.order - b.order);
Expand Down Expand Up @@ -114,7 +137,7 @@ export class VapourSynthScriptGenerator {
const filterUseFp32 = configManager.isModelFp32(filter.modelPath);
const filterModelType = configManager.getModelType(filter.modelPath);
const filterTemporalFrames = configManager.getTemporalFrames(filter.modelPath);
filterCode += this.generateAIModelCode(filter, config.useDirectML || false, filterUseFp32, filterModelType, defaultMatrix, defaultPrimaries, defaultTransfer, config.numStreams, filterTemporalFrames);
filterCode += this.generateAIModelCode(filter, config.useDirectML || false, filterUseFp32, filterModelType, defaultMatrix, defaultPrimaries, defaultTransfer, config.numStreams, filterTemporalFrames, finalDeviceId);
} else if (filter.filterType === 'custom' && filter.code.trim()) {
// Insert custom filter code
filterCode += '# Custom Filter: ' + (filter.preset || 'Unnamed') + '\n';
Expand All @@ -128,7 +151,7 @@ export class VapourSynthScriptGenerator {
filterCode += `clip.set_output(${outputIndex})\n\n`;
}
}

// Replace all placeholders
template = template
.replace(/{{INPUT_VIDEO}}/g, config.inputVideo.replace(/\\/g, '/'))
Expand Down Expand Up @@ -159,7 +182,7 @@ export class VapourSynthScriptGenerator {
/**
* Generate VapourSynth code for an AI model filter
*/
private generateAIModelCode(filter: Filter, useDirectML: boolean, useFp32: boolean, modelType: ModelType, defaultMatrix: string, defaultPrimaries: string, defaultTransfer: string, numStreams?: number, temporalFrames?: number): string {
private generateAIModelCode(filter: Filter, useDirectML: boolean, useFp32: boolean, modelType: ModelType, defaultMatrix: string, defaultPrimaries: string, defaultTransfer: string, numStreams?: number, temporalFrames?: number, deviceId?: number): string {
if (!filter.modelPath) return '';

// Constants for VapourSynth variable names
Expand Down Expand Up @@ -190,12 +213,12 @@ export class VapourSynthScriptGenerator {
modelPathParam = 'network_path';
modelPath = filter.modelPath.replace(/\.engine$/, '.onnx');
const useFp16 = !useFp32;
fp16Param = `, provider="DML", device_id=0, fp16=${useFp16 ? 'True' : 'False'}, verbosity=4`;
fp16Param = `, provider="DML", device_id=${deviceId ?? 0}, fp16=${useFp16 ? 'True' : 'False'}, verbosity=4`;
} else {
modelPlugin = 'trt';
modelPathParam = 'engine_path';
modelPath = filter.modelPath;
fp16Param = '';
fp16Param = `, device_id=${deviceId ?? 0}`;
}

// Determine num_streams value (default to 2 if not specified)
Expand Down
122 changes: 119 additions & 3 deletions electron/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// electron/utils.ts
import { spawn, ChildProcess } from 'child_process';
import { spawn } from 'child_process';
import * as path from 'path';
import * as si from 'systeminformation';
import { logger } from './logger';
import { PATHS } from './constants';

Expand Down Expand Up @@ -147,6 +148,75 @@ export async function withLogSeparator<T>(
}
}

/**
* A detected GPU device from WMI enumeration
*/
export interface GpuDevice {
index: number;
name: string;
adapterRAM: number; // MB, 0 if unknown
vendor: 'nvidia' | 'amd' | 'intel' | 'other';
}

/**
* Enumerates available GPUs via systeminformation.
* The controller array order matches DXGI adapter indices.
* Results are cached after the first successful call.
*/
let gpuCache: GpuDevice[] | null = null;

export async function enumerateGpus(): Promise<GpuDevice[]> {
if (gpuCache) return gpuCache;

try {
const graphics = await si.graphics();
const devices: GpuDevice[] = [];

graphics.controllers.forEach((controller, index) => {
const name = controller.model || controller.name || 'Unknown GPU';

// Filter out software renderers and basic display adapters
const lower = name.toLowerCase();
if (lower.includes('microsoft') || lower.includes('basic')) return;

const lowerVendor = (controller.vendor || '').toLowerCase();
let vendor: GpuDevice['vendor'] = 'other';
if (lowerVendor.includes('nvidia') || lower.includes('nvidia')) vendor = 'nvidia';
else if (lowerVendor.includes('amd') || lower.includes('radeon')) vendor = 'amd';
else if (lowerVendor.includes('intel') || lower.includes('intel')) vendor = 'intel';

devices.push({
index,
name,
adapterRAM: controller.vram || 0, // MB, from systeminformation
vendor
});
});

logger.info(`Enumerated ${devices.length} GPU(s): ${devices.map(g => g.name).join(', ')}`);
gpuCache = devices;
return devices;
} catch (error) {
logger.error(`GPU enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}

/**
* Returns the cached GPU list without re-enumerating.
* Returns an empty array if enumeration has not occurred yet.
*/
export function getCachedGpus(): GpuDevice[] {
return gpuCache || [];
}

/**
* Clears the cached GPU list. Useful for testing or manual refresh.
*/
export function clearGpuCache(): void {
gpuCache = null;
}

/**
* GPU stats returned by pollGpuStats
*/
Expand All @@ -156,13 +226,53 @@ export interface GpuStats {
gpuUtilization: number;
}

/**
* Resolves the path to nvidia-smi.exe by checking PATH first,
* then falling back to the standard system location.
* Returns the command string (may be just 'nvidia-smi' if found on PATH)
* or null if not found anywhere.
*/
const NVIDIA_SMI_PATH = 'C:\\Windows\\System32\\nvidia-smi.exe';

async function resolveNvidiaSmiPath(): Promise<string | null> {
// Try PATH first (fast path)
try {
const pathCheck = await new Promise<boolean>((resolve) => {
const proc = spawn('nvidia-smi', ['--version'], {
shell: true,
windowsHide: true,
stdio: 'ignore'
});
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
setTimeout(() => { proc.kill(); resolve(false); }, 2000);
});
if (pathCheck) return 'nvidia-smi';
} catch {
// fall through
}

// Fallback: standard system location
try {
const exists = require('fs').existsSync(NVIDIA_SMI_PATH);
if (exists) return NVIDIA_SMI_PATH;
} catch {
// fall through
}

return null;
}

/**
* Polls nvidia-smi for GPU memory and utilization stats.
* Returns null if nvidia-smi is unavailable (non-NVIDIA systems).
*/
export async function pollGpuStats(): Promise<GpuStats | null> {
try {
const proc = spawn('nvidia-smi', [
const nvidiaSmi = await resolveNvidiaSmiPath();
if (!nvidiaSmi) return null;

const proc = spawn(nvidiaSmi, [
'--query-gpu=memory.used,memory.total,utilization.gpu',
'--format=csv,noheader,nounits'
], {
Expand Down Expand Up @@ -211,8 +321,14 @@ export async function pollGpuStats(): Promise<GpuStats | null> {
*/
export async function detectCudaSupport(): Promise<boolean> {
try {
const nvidiaSmi = await resolveNvidiaSmiPath();
if (!nvidiaSmi) {
logger.info('nvidia-smi not found - no CUDA support');
return false;
}

// Try to run nvidia-smi to detect NVIDIA GPU
const proc = spawn('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], {
const proc = spawn(nvidiaSmi, ['--query-gpu=name', '--format=csv,noheader'], {
shell: true,
windowsHide: true
});
Expand Down
Loading