mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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:
parent
35b7103c26
commit
d4e645defc
6 changed files with 217 additions and 29 deletions
|
|
@ -58,6 +58,20 @@ const WAN2GP_CATALOG = [
|
||||||
defaultGuidance: 5.0,
|
defaultGuidance: 5.0,
|
||||||
tags: ['video', 'wan', 'text-to-video'],
|
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',
|
id: 'wan2gp:hunyuan-video',
|
||||||
name: 'Hunyuan Video (Wan2GP)',
|
name: 'Hunyuan Video (Wan2GP)',
|
||||||
|
|
@ -100,6 +114,10 @@ function normalizeUrl(url) { return (url || '').trim().replace(/\/+$/, ''); }
|
||||||
// ─── State ────────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
let activeAbort = null;
|
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 ─────────────────────────────────────────────────────────────
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||||
function httpJson(urlStr, { method = 'GET', body = null, timeoutMs = 5000 } = {}) {
|
function httpJson(urlStr, { method = 'GET', body = null, timeoutMs = 5000 } = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
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() {
|
async function listModels() {
|
||||||
const { url } = readConfig();
|
const { url } = readConfig();
|
||||||
const reachable = url ? (await probe(url)).ok : false;
|
const reachable = url ? (await probe(url)).ok : false;
|
||||||
|
|
@ -233,13 +282,29 @@ async function generate(params, mainWindow) {
|
||||||
const steps = params.steps ?? model.defaultSteps;
|
const steps = params.steps ?? model.defaultSteps;
|
||||||
const guidance = params.guidance_scale ?? model.defaultGuidance;
|
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.
|
// Generic positional input — adjust upstream `fn` if signature differs.
|
||||||
const payload = {
|
const payload = {
|
||||||
data: [
|
data: [
|
||||||
params.prompt || '',
|
params.prompt || '',
|
||||||
params.negative_prompt || '',
|
params.negative_prompt || '',
|
||||||
width, height, steps, guidance, seed,
|
width, height, steps, guidance, seed,
|
||||||
params.image || null,
|
imageDescriptor,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -282,6 +347,7 @@ function register() {
|
||||||
ipcMain.handle('wan2gp:list-models', () => listModels());
|
ipcMain.handle('wan2gp:list-models', () => listModels());
|
||||||
ipcMain.handle('wan2gp:generate', (_, params) => generate(params, getMainWindow()));
|
ipcMain.handle('wan2gp:generate', (_, params) => generate(params, getMainWindow()));
|
||||||
ipcMain.handle('wan2gp:cancel-generation', () => cancelGeneration());
|
ipcMain.handle('wan2gp:cancel-generation', () => cancelGeneration());
|
||||||
|
ipcMain.handle('wan2gp:upload-file', (_, payload) => uploadFile(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { register, WAN2GP_CATALOG };
|
module.exports = { register, WAN2GP_CATALOG };
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('localAI', {
|
||||||
listModels: () => ipcRenderer.invoke('wan2gp:list-models'),
|
listModels: () => ipcRenderer.invoke('wan2gp:list-models'),
|
||||||
generate: (params) => ipcRenderer.invoke('wan2gp:generate', params),
|
generate: (params) => ipcRenderer.invoke('wan2gp:generate', params),
|
||||||
cancelGeneration: () => ipcRenderer.invoke('wan2gp:cancel-generation'),
|
cancelGeneration: () => ipcRenderer.invoke('wan2gp:cancel-generation'),
|
||||||
|
uploadFile: (payload) => ipcRenderer.invoke('wan2gp:upload-file', payload),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Progress events — both engines emit on local-ai:progress
|
// Progress events — both engines emit on local-ai:progress
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '.
|
||||||
* @param {number} [options.maxImages=1] - Maximum number of images selectable
|
* @param {number} [options.maxImages=1] - Maximum number of images selectable
|
||||||
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
|
* @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 panelOpen = false;
|
||||||
let maxImages = initialMaxImages;
|
let maxImages = initialMaxImages;
|
||||||
let selectedEntries = []; // [{ url, thumbnail }, ...]
|
let selectedEntries = []; // [{ url, thumbnail }, ...]
|
||||||
|
|
@ -318,10 +323,12 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
const apiKey = localStorage.getItem('muapi_key');
|
if (needsKey()) {
|
||||||
if (!apiKey) {
|
const apiKey = localStorage.getItem('muapi_key');
|
||||||
AuthModal(() => fileInput.click());
|
if (!apiKey) {
|
||||||
return;
|
AuthModal(() => fileInput.click());
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showSpinner();
|
showSpinner();
|
||||||
|
|
@ -330,10 +337,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
if (maxImages === 1) {
|
if (maxImages === 1) {
|
||||||
// Single mode: upload first file only, replace selection
|
// Single mode: upload first file only, replace selection
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
const [uploadResult, thumbnail] = await Promise.all([
|
||||||
muapi.uploadFile(file),
|
doUpload(file),
|
||||||
generateThumbnail(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() };
|
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
||||||
saveUpload(entry);
|
saveUpload(entry);
|
||||||
selectedEntries = [{ url: uploadedUrl, thumbnail }];
|
selectedEntries = [{ url: uploadedUrl, thumbnail }];
|
||||||
|
|
@ -346,10 +354,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
|
|
||||||
// Upload all in parallel
|
// Upload all in parallel
|
||||||
const results = await Promise.all(toUpload.map(async (file) => {
|
const results = await Promise.all(toUpload.map(async (file) => {
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
const [uploadResult, thumbnail] = await Promise.all([
|
||||||
muapi.uploadFile(file),
|
doUpload(file),
|
||||||
generateThumbnail(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() };
|
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,36 @@ import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResol
|
||||||
import { AuthModal } from './AuthModal.js';
|
import { AuthModal } from './AuthModal.js';
|
||||||
import { createUploadPicker } from './UploadPicker.js';
|
import { createUploadPicker } from './UploadPicker.js';
|
||||||
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.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() {
|
export function VideoStudio() {
|
||||||
const container = document.createElement('div');
|
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';
|
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 ---
|
// --- State ---
|
||||||
const defaultModel = t2vModels[0];
|
const defaultModel = allT2V[0];
|
||||||
let selectedModel = defaultModel.id;
|
let selectedModel = defaultModel.id;
|
||||||
let selectedModelName = defaultModel.name;
|
let selectedModelName = defaultModel.name;
|
||||||
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '16:9';
|
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 v2vMode = false; // true = video-to-video tools mode
|
||||||
let uploadedVideoUrl = null;
|
let uploadedVideoUrl = null;
|
||||||
|
|
||||||
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? i2vModels : t2vModels);
|
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? allI2V : allT2V);
|
||||||
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
|
// Local Wan2GP entries don't live in the Muapi-derived helpers, so we
|
||||||
const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
|
// resolve aspect ratios off the catalog when the selected id is local.
|
||||||
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
|
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 getCurrentModes = (id) => getModesForModel(id);
|
||||||
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
|
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
|
||||||
const getQualitiesForModel = (id) => {
|
const getQualitiesForModel = (id) => {
|
||||||
|
|
@ -94,8 +129,8 @@ export function VideoStudio() {
|
||||||
}
|
}
|
||||||
if (!imageMode) {
|
if (!imageMode) {
|
||||||
imageMode = true;
|
imageMode = true;
|
||||||
selectedModel = i2vModels[0].id;
|
selectedModel = allI2V[0].id;
|
||||||
selectedModelName = i2vModels[0].name;
|
selectedModelName = allI2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
}
|
}
|
||||||
|
|
@ -105,13 +140,17 @@ export function VideoStudio() {
|
||||||
onClear: () => {
|
onClear: () => {
|
||||||
uploadedImageUrl = null;
|
uploadedImageUrl = null;
|
||||||
imageMode = false;
|
imageMode = false;
|
||||||
selectedModel = t2vModels[0].id;
|
selectedModel = allT2V[0].id;
|
||||||
selectedModelName = t2vModels[0].name;
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
textarea.disabled = false;
|
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);
|
topRow.appendChild(picker.trigger);
|
||||||
container.appendChild(picker.panel);
|
container.appendChild(picker.panel);
|
||||||
|
|
@ -172,8 +211,8 @@ export function VideoStudio() {
|
||||||
uploadedVideoUrl = null;
|
uploadedVideoUrl = null;
|
||||||
v2vMode = false;
|
v2vMode = false;
|
||||||
showVideoIcon();
|
showVideoIcon();
|
||||||
selectedModel = t2vModels[0].id;
|
selectedModel = allT2V[0].id;
|
||||||
selectedModelName = t2vModels[0].name;
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
|
|
@ -502,7 +541,7 @@ export function VideoStudio() {
|
||||||
const lf = filter.toLowerCase();
|
const lf = filter.toLowerCase();
|
||||||
|
|
||||||
// Regular generation models (always t2v or i2v, never v2v)
|
// Regular generation models (always t2v or i2v, never v2v)
|
||||||
const generationModels = imageMode ? i2vModels : t2vModels;
|
const generationModels = imageMode ? allI2V : allT2V;
|
||||||
const filteredMain = generationModels
|
const filteredMain = generationModels
|
||||||
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
|
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
|
||||||
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
|
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
|
||||||
|
|
@ -926,8 +965,8 @@ export function VideoStudio() {
|
||||||
uploadedVideoUrl = null;
|
uploadedVideoUrl = null;
|
||||||
v2vMode = false;
|
v2vMode = false;
|
||||||
showVideoIcon();
|
showVideoIcon();
|
||||||
selectedModel = t2vModels[0].id;
|
selectedModel = allT2V[0].id;
|
||||||
selectedModelName = t2vModels[0].name;
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
|
|
@ -980,16 +1019,30 @@ export function VideoStudio() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = localStorage.getItem('muapi_key');
|
const isLocal = isWan2gpModelId(selectedModel);
|
||||||
if (!apiKey) {
|
|
||||||
AuthModal(() => generateBtn.click());
|
// Local Wan2GP generations don't go through Muapi — skip the auth gate.
|
||||||
return;
|
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');
|
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
|
||||||
generateBtn.disabled = true;
|
generateBtn.disabled = true;
|
||||||
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
|
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 hadError = false;
|
||||||
let capturedRequestId = null;
|
let capturedRequestId = null;
|
||||||
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
|
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
|
||||||
|
|
@ -1000,6 +1053,32 @@ export function VideoStudio() {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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) {
|
if (v2vMode) {
|
||||||
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
|
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
|
||||||
console.log('[VideoStudio] V2V response:', res);
|
console.log('[VideoStudio] V2V response:', res);
|
||||||
|
|
@ -1119,6 +1198,7 @@ export function VideoStudio() {
|
||||||
}, 4000);
|
}, 4000);
|
||||||
} finally {
|
} finally {
|
||||||
generateBtn.disabled = false;
|
generateBtn.disabled = false;
|
||||||
|
if (typeof unsubscribeProgress === 'function') unsubscribeProgress();
|
||||||
// Only reset the label on success; the catch timeout handles the error case
|
// Only reset the label on success; the catch timeout handles the error case
|
||||||
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
|
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,19 @@ class LocalInferenceClient {
|
||||||
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
|
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
|
||||||
return window.localAI.wan2gp.probe(url);
|
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) ────────────────────────
|
// ── Unified model list (both providers merged) ────────────────────────
|
||||||
async listModels() {
|
async listModels() {
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,19 @@ export const LOCAL_MODEL_CATALOG = [
|
||||||
defaultGuidance: 5.0,
|
defaultGuidance: 5.0,
|
||||||
tags: ['video', 'wan', 'text-to-video'],
|
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',
|
id: 'wan2gp:hunyuan-video',
|
||||||
name: 'Hunyuan Video (Wan2GP)',
|
name: 'Hunyuan Video (Wan2GP)',
|
||||||
|
|
@ -155,3 +168,9 @@ export const LOCAL_MODEL_CATALOG = [
|
||||||
export function getLocalModelById(id) {
|
export function getLocalModelById(id) {
|
||||||
return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null;
|
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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue