fix(local): route Video Studio uploads + generate through Wan2GP when a local model is selected

Closes the gap reported in #126 where local users hit "Not authorized:
missing or invalid credentials" on upload and couldn't generate video
locally even with WanGP installed. Image, video, and reference uploads
were all hard-wired to the Muapi-hosted upload endpoint, and Video
Studio had no branch into the local Wan2GP provider for generate.

- electron/wan2gpProvider: new wan2gp:upload-file IPC that POSTs to the
  Gradio /upload endpoint, caches the returned path, and rehydrates it
  into a Gradio FileData descriptor on generate. Adds wan2gp:wan22-i2v.
- preload + localInferenceClient: expose uploadFileToWan2gp(file).
- localModels: wan22-i2v entry, isWan2gpModelId, localT2VModels/localI2VModels.
- UploadPicker: accept optional uploadFn + requireApiKey so callers can
  bypass the Muapi auth modal when the active provider is local.
- VideoStudio: merge Wan2GP video models into t2v/i2v lists, route the
  reference-image upload through the local provider when a Wan2GP model
  is selected, skip the Muapi key gate for local generations, call
  localAI.generate, and surface step progress in the button label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anil Matcha 2026-04-27 19:55:35 +05:30
commit d4e645defc
6 changed files with 217 additions and 29 deletions

View file

@ -58,6 +58,20 @@ const WAN2GP_CATALOG = [
defaultGuidance: 5.0,
tags: ['video', 'wan', 'text-to-video'],
},
{
id: 'wan2gp:wan22-i2v',
name: 'Wan 2.2 (Image-to-Video)',
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
fn: 'wan22_i2v',
needsImage: true,
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'image-to-video'],
},
{
id: 'wan2gp:hunyuan-video',
name: 'Hunyuan Video (Wan2GP)',
@ -100,6 +114,10 @@ function normalizeUrl(url) { return (url || '').trim().replace(/\/+$/, ''); }
// ─── State ────────────────────────────────────────────────────────────────────
let activeAbort = null;
// Map of uploaded source URL → { path, url, orig_name } so generate() can
// rehydrate the Gradio file descriptor when the renderer passes the URL back.
const uploadedFiles = new Map();
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
function httpJson(urlStr, { method = 'GET', body = null, timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
@ -135,6 +153,37 @@ async function probe(url) {
}
}
// ─── Upload (Gradio v4 /upload) ───────────────────────────────────────────────
// Renderer hands us { name, type, bytes:Uint8Array }. We POST as multipart to
// <base>/upload?upload_id=<id>; Gradio replies with an array of server paths.
// We expose those as a stable HTTP URL the renderer can preview AND stash the
// raw path for generate() to feed back into Gradio's file descriptor.
async function uploadFile({ name, type, bytes }) {
const { url } = readConfig();
if (!url) throw new Error('Wan2GP server URL not set. Open Settings → Local Models to configure.');
const base = normalizeUrl(url);
if (!bytes || !bytes.length) throw new Error('Empty file payload');
const safeName = name || 'upload.bin';
const mime = type || 'application/octet-stream';
const blob = new Blob([new Uint8Array(bytes)], { type: mime });
const form = new FormData();
form.append('files', blob, safeName);
const uploadId = Math.random().toString(36).slice(2, 12);
const res = await fetch(`${base}/upload?upload_id=${uploadId}`, { method: 'POST', body: form });
if (!res.ok) throw new Error(`Wan2GP upload failed: HTTP ${res.status}`);
const paths = await res.json();
const path = Array.isArray(paths) ? paths[0] : paths;
if (!path || typeof path !== 'string') throw new Error('Wan2GP upload returned no path');
const fileUrl = `${base}/file=${path.replace(/^\/+/, '')}`;
uploadedFiles.set(fileUrl, { path, url: fileUrl, orig_name: safeName, mime_type: mime });
return { url: fileUrl, path };
}
async function listModels() {
const { url } = readConfig();
const reachable = url ? (await probe(url)).ok : false;
@ -233,13 +282,29 @@ async function generate(params, mainWindow) {
const steps = params.steps ?? model.defaultSteps;
const guidance = params.guidance_scale ?? model.defaultGuidance;
// Image input → resolve to a Gradio file descriptor if we uploaded it.
let imageDescriptor = null;
if (params.image) {
const cached = uploadedFiles.get(params.image);
if (cached) {
imageDescriptor = { path: cached.path, url: cached.url, orig_name: cached.orig_name, mime_type: cached.mime_type, meta: { _type: 'gradio.FileData' } };
} else if (typeof params.image === 'string') {
imageDescriptor = params.image; // raw URL — Gradio fetches it
} else {
imageDescriptor = params.image;
}
}
if (model.needsImage && !imageDescriptor) {
throw new Error(`${model.name} requires a start-frame image — upload one first.`);
}
// Generic positional input — adjust upstream `fn` if signature differs.
const payload = {
data: [
params.prompt || '',
params.negative_prompt || '',
width, height, steps, guidance, seed,
params.image || null,
imageDescriptor,
],
};
@ -282,6 +347,7 @@ function register() {
ipcMain.handle('wan2gp:list-models', () => listModels());
ipcMain.handle('wan2gp:generate', (_, params) => generate(params, getMainWindow()));
ipcMain.handle('wan2gp:cancel-generation', () => cancelGeneration());
ipcMain.handle('wan2gp:upload-file', (_, payload) => uploadFile(payload));
}
module.exports = { register, WAN2GP_CATALOG };

View file

@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('localAI', {
listModels: () => ipcRenderer.invoke('wan2gp:list-models'),
generate: (params) => ipcRenderer.invoke('wan2gp:generate', params),
cancelGeneration: () => ipcRenderer.invoke('wan2gp:cancel-generation'),
uploadFile: (payload) => ipcRenderer.invoke('wan2gp:upload-file', payload),
},
// Progress events — both engines emit on local-ai:progress

View file

@ -13,7 +13,12 @@ import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '.
* @param {number} [options.maxImages=1] - Maximum number of images selectable
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
*/
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1 }) {
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1, uploadFn, requireApiKey }) {
// uploadFn(file) → Promise<string url>. Defaults to Muapi-hosted upload.
// requireApiKey() → boolean. Lets the caller suppress the AuthModal when
// the active provider doesn't need a Muapi key (e.g. local Wan2GP).
const doUpload = uploadFn || ((file) => muapi.uploadFile(file));
const needsKey = typeof requireApiKey === 'function' ? requireApiKey : () => true;
let panelOpen = false;
let maxImages = initialMaxImages;
let selectedEntries = []; // [{ url, thumbnail }, ...]
@ -318,10 +323,12 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
const files = Array.from(e.target.files);
if (!files.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
if (needsKey()) {
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
}
}
showSpinner();
@ -330,10 +337,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
if (maxImages === 1) {
// Single mode: upload first file only, replace selection
const file = files[0];
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
saveUpload(entry);
selectedEntries = [{ url: uploadedUrl, thumbnail }];
@ -346,10 +354,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
// Upload all in parallel
const results = await Promise.all(toUpload.map(async (file) => {
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
}));

View file

@ -3,13 +3,36 @@ import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResol
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
import { isWan2gpModelId, getLocalModelById, localT2VModels, localI2VModels } from '../lib/localModels.js';
// Promotes a wan2gp catalog entry (lib/localModels.js shape) into the
// `inputs`-shaped descriptor the Video Studio dropdowns/controls expect.
const adaptLocalToVideoEntry = (m) => ({
id: m.id,
name: m.name,
provider: 'wan2gp',
inputs: {
prompt: { type: 'string', name: 'prompt', title: 'Prompt' },
aspect_ratio: { type: 'string', name: 'aspect_ratio', enum: m.aspectRatios || ['16:9', '1:1', '9:16'], default: (m.aspectRatios || ['16:9'])[0] },
},
});
export function VideoStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
// Merge Wan2GP video models in only when running inside Electron AND the
// user has a Wan2GP server configured. We can't probe synchronously, so
// we always include them when isLocalAIAvailable() — getCurrentModel()
// reads from these arrays, so they need to be present from init.
const localT2V = isLocalAIAvailable() ? localT2VModels.map(adaptLocalToVideoEntry) : [];
const localI2V = isLocalAIAvailable() ? localI2VModels.map(adaptLocalToVideoEntry) : [];
const allT2V = [...t2vModels, ...localT2V];
const allI2V = [...i2vModels, ...localI2V];
// --- State ---
const defaultModel = t2vModels[0];
const defaultModel = allT2V[0];
let selectedModel = defaultModel.id;
let selectedModelName = defaultModel.name;
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '16:9';
@ -26,10 +49,22 @@ export function VideoStudio() {
let v2vMode = false; // true = video-to-video tools mode
let uploadedVideoUrl = null;
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? i2vModels : t2vModels);
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? allI2V : allT2V);
// Local Wan2GP entries don't live in the Muapi-derived helpers, so we
// resolve aspect ratios off the catalog when the selected id is local.
const getCurrentAspectRatios = (id) => {
const local = getLocalModelById(id);
if (local) return local.aspectRatios || ['16:9', '1:1', '9:16'];
return imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
};
const getCurrentDurations = (id) => {
if (getLocalModelById(id)) return [];
return imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
};
const getCurrentResolutions = (id) => {
if (getLocalModelById(id)) return [];
return imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
};
const getCurrentModes = (id) => getModesForModel(id);
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
const getQualitiesForModel = (id) => {
@ -94,8 +129,8 @@ export function VideoStudio() {
}
if (!imageMode) {
imageMode = true;
selectedModel = i2vModels[0].id;
selectedModelName = i2vModels[0].name;
selectedModel = allI2V[0].id;
selectedModelName = allI2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
}
@ -105,13 +140,17 @@ export function VideoStudio() {
onClear: () => {
uploadedImageUrl = null;
imageMode = false;
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
textarea.disabled = false;
}
},
// Route the upload through the configured Wan2GP server when the active
// model is local; otherwise fall back to the Muapi-hosted upload.
uploadFn: (file) => isWan2gpModelId(selectedModel) ? localAI.uploadFileToWan2gp(file) : muapi.uploadFile(file),
requireApiKey: () => !isWan2gpModelId(selectedModel),
});
topRow.appendChild(picker.trigger);
container.appendChild(picker.panel);
@ -172,8 +211,8 @@ export function VideoStudio() {
uploadedVideoUrl = null;
v2vMode = false;
showVideoIcon();
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
@ -502,7 +541,7 @@ export function VideoStudio() {
const lf = filter.toLowerCase();
// Regular generation models (always t2v or i2v, never v2v)
const generationModels = imageMode ? i2vModels : t2vModels;
const generationModels = imageMode ? allI2V : allT2V;
const filteredMain = generationModels
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
@ -926,8 +965,8 @@ export function VideoStudio() {
uploadedVideoUrl = null;
v2vMode = false;
showVideoIcon();
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
@ -980,16 +1019,30 @@ export function VideoStudio() {
}
}
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());
return;
const isLocal = isWan2gpModelId(selectedModel);
// Local Wan2GP generations don't go through Muapi — skip the auth gate.
if (!isLocal) {
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());
return;
}
}
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
// For local generations, surface step progress in the button label.
let unsubscribeProgress = null;
if (isLocal) {
unsubscribeProgress = localAI.onProgress(({ status, progress }) => {
const pct = typeof progress === 'number' ? Math.round(progress * 100) : null;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status || 'Generating'}${pct != null ? ` ${pct}%` : '…'}`;
});
}
let hadError = false;
let capturedRequestId = null;
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
@ -1000,6 +1053,32 @@ export function VideoStudio() {
};
try {
// ─── Local Wan2GP path ───────────────────────────────────────────
// Uploaded image URLs were minted by uploadFileToWan2gp(), so
// wan2gpProvider can rehydrate the Gradio file descriptor.
if (isLocal) {
const localParams = {
model: selectedModel,
prompt: prompt || '',
aspect_ratio: selectedAr,
};
if (imageMode && uploadedImageUrl) localParams.image = uploadedImageUrl;
const res = await localAI.generate(localParams);
console.log('[VideoStudio] Local response:', res);
if (res && res.url) {
const genId = Date.now().toString();
lastGenerationId = null;
lastGenerationModel = null;
addToHistory({ id: genId, url: res.url, prompt, model: selectedModel, aspect_ratio: selectedAr, timestamp: new Date().toISOString() });
showVideoInCanvas(res.url, selectedModel);
} else {
throw new Error('No video URL returned by Wan2GP');
}
generateBtn.disabled = false;
generateBtn.innerHTML = `Generate ✨`;
return;
}
if (v2vMode) {
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
console.log('[VideoStudio] V2V response:', res);
@ -1119,6 +1198,7 @@ export function VideoStudio() {
}, 4000);
} finally {
generateBtn.disabled = false;
if (typeof unsubscribeProgress === 'function') unsubscribeProgress();
// Only reset the label on success; the catch timeout handles the error case
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
}

View file

@ -44,6 +44,19 @@ class LocalInferenceClient {
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
return window.localAI.wan2gp.probe(url);
}
// Pushes a File/Blob to the configured Wan2GP server's /upload endpoint
// and returns { url, path }. URL is a previewable HTTP link; the provider
// also remembers the path so a subsequent generate(params.image=url) call
// can rehydrate it as a Gradio file descriptor.
async uploadFileToWan2gp(file) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
const buf = await file.arrayBuffer();
return window.localAI.wan2gp.uploadFile({
name: file.name,
type: file.type,
bytes: new Uint8Array(buf),
});
}
// ── Unified model list (both providers merged) ────────────────────────
async listModels() {

View file

@ -126,6 +126,19 @@ export const LOCAL_MODEL_CATALOG = [
defaultGuidance: 5.0,
tags: ['video', 'wan', 'text-to-video'],
},
{
id: 'wan2gp:wan22-i2v',
name: 'Wan 2.2 (Image-to-Video)',
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
needsImage: true,
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'image-to-video'],
},
{
id: 'wan2gp:hunyuan-video',
name: 'Hunyuan Video (Wan2GP)',
@ -155,3 +168,9 @@ export const LOCAL_MODEL_CATALOG = [
export function getLocalModelById(id) {
return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null;
}
export const isWan2gpModelId = (id) => getLocalModelById(id)?.provider === 'wan2gp';
export const isLocalModelId = (id) => !!getLocalModelById(id);
export const localT2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && !m.needsImage);
export const localI2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && m.needsImage);