diff --git a/electron/dependencyHandlers.ts b/electron/dependencyHandlers.ts index 0f8211c..7705ba1 100644 --- a/electron/dependencyHandlers.ts +++ b/electron/dependencyHandlers.ts @@ -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'; @@ -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', diff --git a/electron/preload.ts b/electron/preload.ts index e30713a..b76281c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); @@ -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), @@ -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) => { @@ -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), diff --git a/electron/scriptGenerator.ts b/electron/scriptGenerator.ts index 57334e1..f645aff 100644 --- a/electron/scriptGenerator.ts +++ b/electron/scriptGenerator.ts @@ -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'; @@ -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 { @@ -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); @@ -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'; @@ -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, '/')) @@ -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 @@ -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) diff --git a/electron/utils.ts b/electron/utils.ts index 8b9034d..e37fbbe 100644 --- a/electron/utils.ts +++ b/electron/utils.ts @@ -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'; @@ -147,6 +148,75 @@ export async function withLogSeparator( } } +/** + * 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 { + 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 */ @@ -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 { + // Try PATH first (fast path) + try { + const pathCheck = await new Promise((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 { 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' ], { @@ -211,8 +321,14 @@ export async function pollGpuStats(): Promise { */ export async function detectCudaSupport(): Promise { 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 }); diff --git a/electron/videoHandlers.ts b/electron/videoHandlers.ts index ef7aa2f..06d9078 100644 --- a/electron/videoHandlers.ts +++ b/electron/videoHandlers.ts @@ -133,7 +133,8 @@ export function registerVideoHandlers( filters?: any[], upscalePosition?: number, numStreams?: number, - sourceFps?: number + sourceFps?: number, + deviceId?: number ) => { logger.info(`Getting output info for: ${videoPath} (validation mode - first 5 seconds)`); try { @@ -153,7 +154,8 @@ export function registerVideoHandlers( numStreams, undefined, // segment true, // validationMode - always enabled for get-output-resolution - sourceFps + sourceFps, + deviceId ); const scriptPath = await scriptGenerator.generateScript(config); @@ -228,7 +230,8 @@ export function registerVideoHandlers( upscalePosition?: number, numStreams?: number, segment?: { enabled: boolean; startFrame: number; endFrame: number }, - benchmarkMode?: boolean + benchmarkMode?: boolean, + deviceId?: number ) => { return await withLogSeparator(async () => { const isUpscaling = upscalingEnabled !== false; // Default to true for backward compatibility @@ -311,7 +314,10 @@ export function registerVideoHandlers( upscalingEnabled, filters, numStreams, - segment + segment, + undefined, // validationMode + undefined, // sourceFps + deviceId ); if (config.upscalingEnabled && config.enginePath) { @@ -475,7 +481,8 @@ export function registerVideoHandlers( upscalingEnabled?: boolean, filters?: any[], numStreams?: number, - segment?: { enabled: boolean; startFrame: number; endFrame: number } + segment?: { enabled: boolean; startFrame: number; endFrame: number }, + deviceId?: number ) => { logger.info(`Launching vs-view for video: ${videoPath}`); try { @@ -496,6 +503,7 @@ export function registerVideoHandlers( outputFormat: 'vs.YUV420P8', segment: segment, sourceFps: metadata.fps, + deviceId: deviceId, generatePreviewOutputs: true // Enable multi-output generation for filter comparison }; @@ -529,7 +537,8 @@ export function registerVideoHandlers( filters?: any[], numStreams?: number, startFrame?: number, - endFrame?: number + endFrame?: number, + deviceId?: number ) => { return await withLogSeparator(async () => { logger.upscale('Starting segment preview'); @@ -568,7 +577,10 @@ export function registerVideoHandlers( upscalingEnabled, filters, numStreams, - previewSegment + previewSegment, + undefined, // validationMode + undefined, // sourceFps + deviceId ); const scriptPath = await scriptGenerator.generateScript(config); @@ -824,7 +836,8 @@ function createScriptConfig( numStreams?: number, segment?: { enabled: boolean; startFrame: number; endFrame: number }, validationMode?: boolean, - sourceFps?: number + sourceFps?: number, + deviceId?: number ) { const isUpscaling = upscalingEnabled !== false; @@ -854,6 +867,7 @@ function createScriptConfig( outputFormat: outputFormat, segment: segment?.enabled ? segment : undefined, validationMode: validationMode, - sourceFps: sourceFps + sourceFps: sourceFps, + deviceId: deviceId }; } diff --git a/package.json b/package.json index 38adbc7..296d884 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-resizable-panels": "^3.0.6", + "systeminformation": "^5.31.8", "zod": "^4.3.6" }, "build": { diff --git a/src/App.tsx b/src/App.tsx index 97369ad..0fd08a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,7 +65,7 @@ function App() { // Setup and initialization hooks const { consoleOutput, consoleEndRef, addConsoleLog } = useConsoleLog(); const { isSetupComplete, isCheckingDeps, hasCudaSupport, setupProgress, isSettingUp, handleSetup, pluginInstallError, handleRetryPlugins, handleContinueWithoutPlugins } = useSetup(addConsoleLog); - const { useDirectML, toggleDirectML, numStreams, updateNumStreams } = useSettings(hasCudaSupport); + const { useDirectML, toggleDirectML, numStreams, updateNumStreams, deviceId, updateDeviceId, availableGpus, isEnumerating } = useSettings(hasCudaSupport); const { privacyMode, togglePrivacyMode } = usePrivacyMode(); const { ffmpegArgs, @@ -185,6 +185,7 @@ function App() { selectedModel, colorimetry: colorimetrySettings, segment, + deviceId, }); // Destructure queue store for convenience @@ -482,6 +483,7 @@ function App() { useDirectML, filters, numStreams, + deviceId, onLog: addConsoleLog, onUpdateVideoInfo: setVideoInfo, onError: (message) => notify.error('Workflow Validation Error', message), @@ -545,7 +547,8 @@ function App() { filters, numStreams, startFrame, - endFrame + endFrame, + deviceId ); if (result.success && result.previewPath) { @@ -577,7 +580,8 @@ function App() { true, filters, numStreams, - segment + segment, + deviceId ); if (result.success) { @@ -933,6 +937,10 @@ function App() { numStreams={numStreams} onUpdateNumStreams={updateNumStreams} onToggleDirectML={handleToggleDirectML} + deviceId={deviceId} + onUpdateDeviceId={updateDeviceId} + availableGpus={availableGpus} + isEnumerating={isEnumerating} videoCompareArgs={videoCompareArgs} onUpdateVideoCompareArgs={handleUpdateVideoCompareArgs} onResetVideoCompareArgs={handleResetVideoCompareArgs} diff --git a/src/components/AppModals.tsx b/src/components/AppModals.tsx index 2e0b91d..bcde902 100644 --- a/src/components/AppModals.tsx +++ b/src/components/AppModals.tsx @@ -9,7 +9,7 @@ import { PluginsModal } from './PluginsModal'; import { UpdateNotificationModal } from './UpdateNotificationModal'; import { VsMlrtUpdateModal } from './VsMlrtUpdateModal'; import { FilterImportModal } from './FilterImportModal'; -import type { UpdateInfo, VsMlrtVersionInfo } from '../electron'; +import type { UpdateInfo, VsMlrtVersionInfo, GpuDevice } from '../electron'; interface AppModalsProps { // Import Model @@ -43,6 +43,10 @@ interface AppModalsProps { numStreams: number; onUpdateNumStreams: (streams: number) => void; onToggleDirectML: (value: boolean) => void; + deviceId: number; + onUpdateDeviceId: (value: number) => void; + availableGpus: GpuDevice[]; + isEnumerating: boolean; videoCompareArgs: string; onUpdateVideoCompareArgs: (args: string) => void; onResetVideoCompareArgs: () => void; @@ -124,6 +128,10 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onResetDefaultOutputFolder={props.onResetDefaultOutputFolder} descriptiveNamingEnabled={props.descriptiveNamingEnabled} onUpdateDescriptiveNamingEnabled={props.onUpdateDescriptiveNamingEnabled} + deviceId={props.deviceId} + onUpdateDeviceId={props.onUpdateDeviceId} + availableGpus={props.availableGpus} + isEnumerating={props.isEnumerating} /> void; descriptiveNamingEnabled: boolean; onUpdateDescriptiveNamingEnabled: (enabled: boolean) => void; + deviceId: number; + onUpdateDeviceId: (value: number) => void; + availableGpus: GpuDevice[]; + isEnumerating: boolean; } type Tab = 'general' | 'processing'; @@ -35,6 +40,10 @@ export const SettingsModal = memo(({ onResetDefaultOutputFolder, descriptiveNamingEnabled, onUpdateDescriptiveNamingEnabled, + deviceId, + onUpdateDeviceId, + availableGpus, + isEnumerating, }) => { const [activeTab, setActiveTab] = useState('general'); const [showVideoCompareOptions, setShowVideoCompareOptions] = useState(false); @@ -199,6 +208,65 @@ export const SettingsModal = memo(({ )} + {/* GPU Device Selection */} +
+