Spaces:
Running
Running
Upload 21 files
Browse files- .dockerignore +1 -1
- .env.example +2 -0
- App.tsx +364 -65
- components/MediaItem.tsx +9 -2
- index.html +0 -1
- package.json +2 -1
- services/geminiService.ts +24 -11
- services/grokService.ts +285 -0
- services/openRouterService.ts +376 -0
- services/qwenService.ts +2 -2
.dockerignore
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
��^�j�W�����$z����b�
|
|
|
|
| 1 |
+
��^�j�W�����$z����b�
|
.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gemini API Key
|
| 2 |
+
GEMINI_API_KEY=
|
App.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
| 2 |
import type { MediaFile } from './types';
|
| 3 |
import { GenerationStatus } from './types';
|
|
@@ -5,12 +6,14 @@ import FileUploader from './components/FileUploader';
|
|
| 5 |
import MediaItem from './components/MediaItem';
|
| 6 |
import { generateCaption, refineCaption, checkCaptionQuality } from './services/geminiService';
|
| 7 |
import { generateCaptionQwen, refineCaptionQwen, checkQualityQwen } from './services/qwenService';
|
|
|
|
|
|
|
| 8 |
import { sendComfyPrompt } from './services/comfyService';
|
| 9 |
import { DownloadIcon, SparklesIcon, WandIcon, LoaderIcon, CopyIcon, UploadCloudIcon, XIcon, CheckCircleIcon, AlertTriangleIcon, StopIcon, TrashIcon } from './components/Icons';
|
| 10 |
import { DEFAULT_COMFY_WORKFLOW } from './constants/defaultWorkflow';
|
| 11 |
|
| 12 |
declare const process: {
|
| 13 |
-
env: {
|
| 14 |
};
|
| 15 |
|
| 16 |
declare global {
|
|
@@ -21,14 +24,15 @@ declare global {
|
|
| 21 |
interface Window { JSZip: any; aistudio?: AIStudio; }
|
| 22 |
}
|
| 23 |
|
| 24 |
-
type ApiProvider = 'gemini' | 'qwen';
|
| 25 |
type OSType = 'windows' | 'linux';
|
| 26 |
|
| 27 |
const GEMINI_MODELS = [
|
| 28 |
-
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (High Quality)' },
|
| 29 |
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Fast)' },
|
| 30 |
-
{ id: 'gemini-
|
| 31 |
-
{ id: 'gemini-
|
|
|
|
| 32 |
];
|
| 33 |
|
| 34 |
const QWEN_MODELS = [
|
|
@@ -37,17 +41,49 @@ const QWEN_MODELS = [
|
|
| 37 |
{ id: 'Qwen/Qwen3-VL-8B-Instruct-FP8', name: 'Qwen 3 VL 8B FP8' },
|
| 38 |
];
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const DEFAULT_BULK_INSTRUCTIONS = `Dont use ambiguous language "perhaps" for example. Describe EVERYTHING visible: characters, clothing, actions, background, objects, lighting, and camera angle. Refrain from using generic phrases like "character, male, figure of" and use specific terminology: "woman, girl, boy, man". Do not mention the art style.`;
|
| 41 |
const DEFAULT_REFINEMENT_INSTRUCTIONS = `Refine the caption to be more descriptive and cinematic. Ensure all colors and materials are mentioned.`;
|
| 42 |
|
| 43 |
const App: React.FC = () => {
|
| 44 |
// --- STATE ---
|
| 45 |
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
|
|
|
| 46 |
const [triggerWord, setTriggerWord] = useState<string>('MyStyle');
|
| 47 |
const [apiProvider, setApiProvider] = useState<ApiProvider>('gemini');
|
| 48 |
-
const [geminiApiKey, setGeminiApiKey] = useState<string>(process.env.
|
| 49 |
const [geminiModel, setGeminiModel] = useState<string>(GEMINI_MODELS[0].id);
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
// Qwen Options
|
| 53 |
const [qwenEndpoint, setQwenEndpoint] = useState<string>('');
|
|
@@ -59,7 +95,7 @@ const App: React.FC = () => {
|
|
| 59 |
const [qwenMaxTokens, setQwenMaxTokens] = useState<number>(8192);
|
| 60 |
const [qwen8Bit, setQwen8Bit] = useState<boolean>(false);
|
| 61 |
const [qwenEager, setQwenEager] = useState<boolean>(false);
|
| 62 |
-
const [qwenVideoFrameCount
|
| 63 |
|
| 64 |
// Offline Local Snapshot Options
|
| 65 |
const [useOfflineSnapshot, setUseOfflineSnapshot] = useState<boolean>(false);
|
|
@@ -79,7 +115,7 @@ const App: React.FC = () => {
|
|
| 79 |
const [useSecureBridge, setUseSecureBridge] = useState<boolean>(false);
|
| 80 |
const [isFirstTimeBridge, setIsFirstTimeBridge] = useState<boolean>(false);
|
| 81 |
const [bridgeOsType, setBridgeOsType] = useState<OSType>(() => navigator.userAgent.includes("Windows") ? 'windows' : 'linux');
|
| 82 |
-
const [bridgeInstallPath, setBridgeInstallPath] = useState<string>('/
|
| 83 |
|
| 84 |
// Queue and Performance
|
| 85 |
const [useRequestQueue, setUseRequestQueue] = useState<boolean>(true);
|
|
@@ -100,9 +136,6 @@ const App: React.FC = () => {
|
|
| 100 |
|
| 101 |
// --- EFFECTS ---
|
| 102 |
useEffect(() => {
|
| 103 |
-
if (window.aistudio) {
|
| 104 |
-
window.aistudio.hasSelectedApiKey().then(setHasSelectedKey);
|
| 105 |
-
}
|
| 106 |
const isHttps = window.location.protocol === 'https:';
|
| 107 |
if (!qwenEndpoint) {
|
| 108 |
setQwenEndpoint(isHttps ? '' : 'http://localhost:8000/v1');
|
|
@@ -124,8 +157,10 @@ const App: React.FC = () => {
|
|
| 124 |
// --- MEMOIZED VALUES ---
|
| 125 |
const hasValidConfig = useMemo(() => {
|
| 126 |
if (apiProvider === 'gemini') return !!geminiApiKey;
|
|
|
|
|
|
|
| 127 |
return qwenEndpoint !== '';
|
| 128 |
-
}, [apiProvider, geminiApiKey, qwenEndpoint]);
|
| 129 |
|
| 130 |
const selectedFiles = useMemo(() => {
|
| 131 |
return (mediaFiles || []).filter(mf => mf.isSelected);
|
|
@@ -175,34 +210,47 @@ const App: React.FC = () => {
|
|
| 175 |
: `cd "${path}" && ${isFirstTimeBridge ? `${setupCmd} && ` : ''}${activateCmd} && python3 bridge.py`;
|
| 176 |
}, [bridgeInstallPath, bridgeOsType, isFirstTimeBridge]);
|
| 177 |
|
| 178 |
-
const isTunnelRequired = useMemo(() => {
|
| 179 |
-
return window.location.protocol === 'https:' && (qwenEndpoint.includes('localhost') || qwenEndpoint.includes('127.0.0.1'));
|
| 180 |
-
}, [qwenEndpoint]);
|
| 181 |
-
|
| 182 |
// --- HANDLERS ---
|
| 183 |
-
const handleSelectApiKey = async () => {
|
| 184 |
-
if (window.aistudio) {
|
| 185 |
-
await window.aistudio.openSelectKey();
|
| 186 |
-
setHasSelectedKey(true);
|
| 187 |
-
}
|
| 188 |
-
};
|
| 189 |
-
|
| 190 |
const updateFile = useCallback((id: string, updates: Partial<MediaFile>) => {
|
| 191 |
setMediaFiles(prev => (prev || []).map(mf => (mf.id === id ? { ...mf, ...updates } : mf)));
|
| 192 |
}, []);
|
| 193 |
|
| 194 |
const handleFilesAdded = useCallback(async (files: File[]) => {
|
| 195 |
-
const
|
| 196 |
-
const
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
setMediaFiles(prev => [...(prev || []), ...newMediaFiles]);
|
| 207 |
}, []);
|
| 208 |
|
|
@@ -213,9 +261,16 @@ const App: React.FC = () => {
|
|
| 213 |
updateFile(id, { status: GenerationStatus.CHECKING, errorMessage: undefined });
|
| 214 |
|
| 215 |
try {
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
updateFile(id, { qualityScore: score, status: GenerationStatus.SUCCESS });
|
| 221 |
} catch (err: any) {
|
|
@@ -225,7 +280,29 @@ const App: React.FC = () => {
|
|
| 225 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 226 |
}
|
| 227 |
}
|
| 228 |
-
}, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
const handleGenerateCaption = useCallback(async (id: string, itemInstructions?: string) => {
|
| 231 |
const fileToProcess = (mediaFiles || []).find(mf => mf.id === id);
|
|
@@ -236,19 +313,29 @@ const App: React.FC = () => {
|
|
| 236 |
const combinedInstructions = `${bulkGenerationInstructions}\n\n${itemInstructions || ''}`.trim();
|
| 237 |
|
| 238 |
try {
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
|
|
|
| 243 |
updateFile(id, { caption, status: GenerationStatus.SUCCESS });
|
| 244 |
} catch (err: any) {
|
|
|
|
| 245 |
if (err.name === 'AbortError' || err.message === 'AbortError') {
|
| 246 |
updateFile(id, { status: GenerationStatus.IDLE, errorMessage: "Stopped by user" });
|
| 247 |
} else {
|
| 248 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 249 |
}
|
| 250 |
}
|
| 251 |
-
}, [mediaFiles, triggerWord, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, bulkGenerationInstructions, isCharacterTaggingEnabled, characterShowName, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
| 252 |
|
| 253 |
const handleRefineCaptionItem = useCallback(async (id: string, itemInstructions?: string) => {
|
| 254 |
const fileToProcess = (mediaFiles || []).find(mf => mf.id === id);
|
|
@@ -259,9 +346,16 @@ const App: React.FC = () => {
|
|
| 259 |
const combinedInstructions = `${bulkRefinementInstructions}\n\n${itemInstructions || ''}`.trim();
|
| 260 |
|
| 261 |
try {
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
updateFile(id, { caption, status: GenerationStatus.SUCCESS });
|
| 267 |
} catch (err: any) {
|
|
@@ -271,14 +365,15 @@ const App: React.FC = () => {
|
|
| 271 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 272 |
}
|
| 273 |
}
|
| 274 |
-
}, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, bulkRefinementInstructions, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
| 275 |
|
| 276 |
// --- QUEUE CONTROLLER ---
|
| 277 |
const runTasksInQueue = async (tasks: (() => Promise<void>)[]) => {
|
|
|
|
| 278 |
setIsQueueRunning(true);
|
| 279 |
const pool = new Set<Promise<void>>();
|
| 280 |
for (const task of tasks) {
|
| 281 |
-
if (
|
| 282 |
const promise = task();
|
| 283 |
pool.add(promise);
|
| 284 |
promise.finally(() => pool.delete(promise));
|
|
@@ -291,6 +386,9 @@ const App: React.FC = () => {
|
|
| 291 |
};
|
| 292 |
|
| 293 |
const handleBulkGenerate = () => {
|
|
|
|
|
|
|
|
|
|
| 294 |
const tasks = selectedFiles.map(file => () => handleGenerateCaption(file.id, file.customInstructions));
|
| 295 |
if (useRequestQueue) {
|
| 296 |
runTasksInQueue(tasks);
|
|
@@ -300,6 +398,9 @@ const App: React.FC = () => {
|
|
| 300 |
};
|
| 301 |
|
| 302 |
const handleBulkRefine = () => {
|
|
|
|
|
|
|
|
|
|
| 303 |
const tasks = selectedFiles.map(file => () => handleRefineCaptionItem(file.id, file.customInstructions));
|
| 304 |
if (useRequestQueue) {
|
| 305 |
runTasksInQueue(tasks);
|
|
@@ -309,6 +410,9 @@ const App: React.FC = () => {
|
|
| 309 |
};
|
| 310 |
|
| 311 |
const handleBulkQualityCheck = () => {
|
|
|
|
|
|
|
|
|
|
| 312 |
const tasks = selectedFiles.map(file => () => handleCheckQuality(file.id));
|
| 313 |
if (useRequestQueue) {
|
| 314 |
runTasksInQueue(tasks);
|
|
@@ -348,11 +452,11 @@ const App: React.FC = () => {
|
|
| 348 |
const remaining = (prev || []).filter(mf => !mf.isSelected);
|
| 349 |
return remaining || [];
|
| 350 |
});
|
|
|
|
| 351 |
}, []);
|
| 352 |
|
| 353 |
const handleStopTasks = () => {
|
| 354 |
abortControllerRef.current.abort();
|
| 355 |
-
abortControllerRef.current = new AbortController();
|
| 356 |
setIsQueueRunning(false);
|
| 357 |
setMediaFiles(prev => (prev || []).map(mf => {
|
| 358 |
if (mf.status === GenerationStatus.GENERATING || mf.status === GenerationStatus.CHECKING) {
|
|
@@ -407,7 +511,7 @@ const App: React.FC = () => {
|
|
| 407 |
const downloadQwenSetupScript = () => {
|
| 408 |
const isWin = qwenOsType === 'windows';
|
| 409 |
const content = isWin
|
| 410 |
-
? `@echo off\npython -m venv venv\ncall venv\\Scripts\\activate\
|
| 411 |
: `#!/bin/bash\npython3 -m venv venv\nsource venv/bin/activate\npip install vllm bitsandbytes\necho Setup Complete.`;
|
| 412 |
const filename = isWin ? 'setup_qwen.bat' : 'setup_qwen.sh';
|
| 413 |
const blob = new Blob([content], { type: 'text/plain' });
|
|
@@ -419,6 +523,21 @@ const App: React.FC = () => {
|
|
| 419 |
URL.revokeObjectURL(url);
|
| 420 |
};
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
const downloadBridgeScript = () => {
|
| 423 |
const code = `import requests\nfrom flask import Flask, request, Response\nfrom flask_cors import CORS\napp = Flask(__name__)\nCORS(app)\nTARGET = "http://127.0.0.1:8188"\n@app.route('/', defaults={'path': ''}, methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\n@app.route('/<path:path>', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\ndef proxy(path):\n url = f"{TARGET}/{path}"\n headers = {k:v for k,v in request.headers.items() if k.lower() not in ['host', 'origin', 'referer']}\n resp = requests.request(method=request.method, url=url, headers=headers, data=request.get_data(), params=request.args, stream=True)\n return Response(resp.content, resp.status_code, [(n,v) for n,v in resp.headers.items() if n.lower() not in ['content-encoding','content-length','transfer-encoding','connection']])\nif __name__ == '__main__': app.run(port=5000, host='0.0.0.0')`;
|
| 424 |
const blob = new Blob([code], { type: 'text/x-python' });
|
|
@@ -493,24 +612,160 @@ const App: React.FC = () => {
|
|
| 493 |
<div>
|
| 494 |
<label className="text-xs font-black text-gray-500 uppercase tracking-widest block mb-4">AI Provider</label>
|
| 495 |
<div className="flex p-1.5 bg-black rounded-2xl border border-gray-800 shadow-inner">
|
| 496 |
-
<button onClick={() => setApiProvider('gemini')} className={`flex-1 py-3 text-
|
| 497 |
-
<button onClick={() => setApiProvider('
|
|
|
|
|
|
|
| 498 |
</div>
|
| 499 |
</div>
|
| 500 |
|
| 501 |
-
{apiProvider === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
<div className="bg-indigo-500/5 border border-indigo-500/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl">
|
| 503 |
<div className="space-y-4">
|
| 504 |
<div className="flex justify-between items-center">
|
| 505 |
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Gemini Model Version</label>
|
| 506 |
</div>
|
| 507 |
-
<
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
</div>
|
| 515 |
|
| 516 |
<div className="space-y-4">
|
|
@@ -537,7 +792,50 @@ const App: React.FC = () => {
|
|
| 537 |
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline font-bold">Google AI Studio</a>
|
| 538 |
</p>
|
| 539 |
</div>
|
| 540 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
<div className="bg-gray-950 p-6 rounded-3xl border border-gray-800 space-y-6 animate-slide-down shadow-xl">
|
| 542 |
<div className="flex justify-between items-center mb-2">
|
| 543 |
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Local Model Configuration</label>
|
|
@@ -563,7 +861,7 @@ const App: React.FC = () => {
|
|
| 563 |
</div>
|
| 564 |
<div className="space-y-1">
|
| 565 |
<label className="text-[9px] font-black text-gray-700 uppercase">Virtual Model Name (Served Name)</label>
|
| 566 |
-
<input type="text" value={virtualModelName} onChange={e => setVirtualModelName(e.target.value)} placeholder="org/model-id..." className="w-full p-
|
| 567 |
</div>
|
| 568 |
</div>
|
| 569 |
) : useCustomQwenModel ? (
|
|
@@ -605,7 +903,7 @@ const App: React.FC = () => {
|
|
| 605 |
</label>
|
| 606 |
</div>
|
| 607 |
|
| 608 |
-
<button onClick={downloadQwenSetupScript} className="w-full py-3 bg-green-700 hover:bg-green-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download
|
| 609 |
|
| 610 |
<div className="space-y-2">
|
| 611 |
<label className="text-[9px] font-black text-gray-700 uppercase">Local Start Command:</label>
|
|
@@ -763,6 +1061,7 @@ const App: React.FC = () => {
|
|
| 763 |
<span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">First-time Setup (Include VENV & Pip Install)</span>
|
| 764 |
</label>
|
| 765 |
<div className="flex gap-4">
|
|
|
|
| 766 |
<button onClick={downloadBridgeScript} className="flex-1 py-3 bg-orange-700 hover:bg-orange-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download Bridge.py</button>
|
| 767 |
</div>
|
| 768 |
</div>
|
|
@@ -861,7 +1160,7 @@ const App: React.FC = () => {
|
|
| 861 |
onPreview={handleComfyPreview}
|
| 862 |
onCaptionChange={(id, cap) => updateFile(id, { caption: cap })}
|
| 863 |
onCustomInstructionsChange={(id, ins) => updateFile(id, { customInstructions: ins })}
|
| 864 |
-
onSelectionChange={
|
| 865 |
onOpenPreviewModal={setActivePreviewId}
|
| 866 |
/>
|
| 867 |
))}
|
|
@@ -880,4 +1179,4 @@ const App: React.FC = () => {
|
|
| 880 |
);
|
| 881 |
};
|
| 882 |
|
| 883 |
-
export default App;
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
| 3 |
import type { MediaFile } from './types';
|
| 4 |
import { GenerationStatus } from './types';
|
|
|
|
| 6 |
import MediaItem from './components/MediaItem';
|
| 7 |
import { generateCaption, refineCaption, checkCaptionQuality } from './services/geminiService';
|
| 8 |
import { generateCaptionQwen, refineCaptionQwen, checkQualityQwen } from './services/qwenService';
|
| 9 |
+
import { generateCaptionGrok, refineCaptionGrok, checkQualityGrok } from './services/grokService';
|
| 10 |
+
import { generateCaptionOpenRouter, refineCaptionOpenRouter, checkQualityOpenRouter } from './services/openRouterService';
|
| 11 |
import { sendComfyPrompt } from './services/comfyService';
|
| 12 |
import { DownloadIcon, SparklesIcon, WandIcon, LoaderIcon, CopyIcon, UploadCloudIcon, XIcon, CheckCircleIcon, AlertTriangleIcon, StopIcon, TrashIcon } from './components/Icons';
|
| 13 |
import { DEFAULT_COMFY_WORKFLOW } from './constants/defaultWorkflow';
|
| 14 |
|
| 15 |
declare const process: {
|
| 16 |
+
env: { GEMINI_API_KEY?: string; [key: string]: string | undefined; }
|
| 17 |
};
|
| 18 |
|
| 19 |
declare global {
|
|
|
|
| 24 |
interface Window { JSZip: any; aistudio?: AIStudio; }
|
| 25 |
}
|
| 26 |
|
| 27 |
+
type ApiProvider = 'gemini' | 'qwen' | 'grok' | 'openrouter';
|
| 28 |
type OSType = 'windows' | 'linux';
|
| 29 |
|
| 30 |
const GEMINI_MODELS = [
|
| 31 |
+
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro (High Quality)' },
|
| 32 |
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Fast)' },
|
| 33 |
+
{ id: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash Lite' },
|
| 34 |
+
{ id: 'gemini-3.1-flash-live-preview', name: 'Gemini 3.1 Flash Live (Native Audio/Video)' },
|
| 35 |
+
{ id: 'gemini-flash-latest', name: 'Gemini Flash Latest' }
|
| 36 |
];
|
| 37 |
|
| 38 |
const QWEN_MODELS = [
|
|
|
|
| 41 |
{ id: 'Qwen/Qwen3-VL-8B-Instruct-FP8', name: 'Qwen 3 VL 8B FP8' },
|
| 42 |
];
|
| 43 |
|
| 44 |
+
const GROK_MODELS = [
|
| 45 |
+
{ id: 'grok-4-1-fast-reasoning', name: 'Grok 4-1 Fast Reasoning' },
|
| 46 |
+
{ id: 'grok-4-1-fast-non-reasoning', name: 'Grok 4-1 Fast Non-Reasoning' },
|
| 47 |
+
{ id: 'grok-4-fast-reasoning', name: 'Grok 4 Fast Reasoning' },
|
| 48 |
+
{ id: 'grok-4-fast-non-reasoning', name: 'Grok 4 Fast Non-Reasoning' },
|
| 49 |
+
{ id: 'grok-3-vision-preview', name: 'Grok 3 Vision (Latest Preview)' },
|
| 50 |
+
{ id: 'grok-2-vision-1212', name: 'Grok 2 Vision (12-12)' },
|
| 51 |
+
{ id: 'grok-2-vision', name: 'Grok 2 Vision (Alias)' },
|
| 52 |
+
{ id: 'grok-vision-beta', name: 'Grok Vision Beta' },
|
| 53 |
+
{ id: 'grok-imagine-video', name: 'Grok Imagine Video' }
|
| 54 |
+
];
|
| 55 |
+
|
| 56 |
+
const OPENROUTER_MODELS = [
|
| 57 |
+
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
|
| 58 |
+
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
| 59 |
+
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
| 60 |
+
{ id: 'qwen/qwen-2-vl-72b-instruct', name: 'Qwen 2 VL 72B' },
|
| 61 |
+
{ id: 'qwen/qwen-2-vl-7b-instruct', name: 'Qwen 2 VL 7B' },
|
| 62 |
+
{ id: 'qwen/qwen3.5-35b-a3b-20260224', name: 'Qwen 3.5 35B (Reasoning)' },
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
const DEFAULT_BULK_INSTRUCTIONS = `Dont use ambiguous language "perhaps" for example. Describe EVERYTHING visible: characters, clothing, actions, background, objects, lighting, and camera angle. Refrain from using generic phrases like "character, male, figure of" and use specific terminology: "woman, girl, boy, man". Do not mention the art style.`;
|
| 66 |
const DEFAULT_REFINEMENT_INSTRUCTIONS = `Refine the caption to be more descriptive and cinematic. Ensure all colors and materials are mentioned.`;
|
| 67 |
|
| 68 |
const App: React.FC = () => {
|
| 69 |
// --- STATE ---
|
| 70 |
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
| 71 |
+
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
|
| 72 |
const [triggerWord, setTriggerWord] = useState<string>('MyStyle');
|
| 73 |
const [apiProvider, setApiProvider] = useState<ApiProvider>('gemini');
|
| 74 |
+
const [geminiApiKey, setGeminiApiKey] = useState<string>(process.env.GEMINI_API_KEY || '');
|
| 75 |
const [geminiModel, setGeminiModel] = useState<string>(GEMINI_MODELS[0].id);
|
| 76 |
+
|
| 77 |
+
// xAI Grok Options
|
| 78 |
+
const [grokApiKey, setGrokApiKey] = useState<string>('');
|
| 79 |
+
const [grokModel, setGrokModel] = useState<string>(GROK_MODELS[0].id);
|
| 80 |
+
|
| 81 |
+
// OpenRouter Options
|
| 82 |
+
const [openRouterApiKey, setOpenRouterApiKey] = useState<string>('');
|
| 83 |
+
const [openRouterModel, setOpenRouterModel] = useState<string>(OPENROUTER_MODELS[0].id);
|
| 84 |
+
const [openRouterMaxTokens, setOpenRouterMaxTokens] = useState<number>(4096);
|
| 85 |
+
const [openRouterTemperature, setOpenRouterTemperature] = useState<number>(0.7);
|
| 86 |
+
const [openRouterUseFullVideo, setOpenRouterUseFullVideo] = useState<boolean>(false);
|
| 87 |
|
| 88 |
// Qwen Options
|
| 89 |
const [qwenEndpoint, setQwenEndpoint] = useState<string>('');
|
|
|
|
| 95 |
const [qwenMaxTokens, setQwenMaxTokens] = useState<number>(8192);
|
| 96 |
const [qwen8Bit, setQwen8Bit] = useState<boolean>(false);
|
| 97 |
const [qwenEager, setQwenEager] = useState<boolean>(false);
|
| 98 |
+
const [qwenVideoFrameCount] = useState<number>(8);
|
| 99 |
|
| 100 |
// Offline Local Snapshot Options
|
| 101 |
const [useOfflineSnapshot, setUseOfflineSnapshot] = useState<boolean>(false);
|
|
|
|
| 115 |
const [useSecureBridge, setUseSecureBridge] = useState<boolean>(false);
|
| 116 |
const [isFirstTimeBridge, setIsFirstTimeBridge] = useState<boolean>(false);
|
| 117 |
const [bridgeOsType, setBridgeOsType] = useState<OSType>(() => navigator.userAgent.includes("Windows") ? 'windows' : 'linux');
|
| 118 |
+
const [bridgeInstallPath, setBridgeInstallPath] = useState<string>(() => navigator.userAgent.includes("Windows") ? 'C:\\AI\\bridge' : '/home/user/ai/bridge');
|
| 119 |
|
| 120 |
// Queue and Performance
|
| 121 |
const [useRequestQueue, setUseRequestQueue] = useState<boolean>(true);
|
|
|
|
| 136 |
|
| 137 |
// --- EFFECTS ---
|
| 138 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
| 139 |
const isHttps = window.location.protocol === 'https:';
|
| 140 |
if (!qwenEndpoint) {
|
| 141 |
setQwenEndpoint(isHttps ? '' : 'http://localhost:8000/v1');
|
|
|
|
| 157 |
// --- MEMOIZED VALUES ---
|
| 158 |
const hasValidConfig = useMemo(() => {
|
| 159 |
if (apiProvider === 'gemini') return !!geminiApiKey;
|
| 160 |
+
if (apiProvider === 'grok') return !!grokApiKey;
|
| 161 |
+
if (apiProvider === 'openrouter') return !!openRouterApiKey;
|
| 162 |
return qwenEndpoint !== '';
|
| 163 |
+
}, [apiProvider, geminiApiKey, grokApiKey, openRouterApiKey, qwenEndpoint]);
|
| 164 |
|
| 165 |
const selectedFiles = useMemo(() => {
|
| 166 |
return (mediaFiles || []).filter(mf => mf.isSelected);
|
|
|
|
| 210 |
: `cd "${path}" && ${isFirstTimeBridge ? `${setupCmd} && ` : ''}${activateCmd} && python3 bridge.py`;
|
| 211 |
}, [bridgeInstallPath, bridgeOsType, isFirstTimeBridge]);
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
// --- HANDLERS ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
const updateFile = useCallback((id: string, updates: Partial<MediaFile>) => {
|
| 215 |
setMediaFiles(prev => (prev || []).map(mf => (mf.id === id ? { ...mf, ...updates } : mf)));
|
| 216 |
}, []);
|
| 217 |
|
| 218 |
const handleFilesAdded = useCallback(async (files: File[]) => {
|
| 219 |
+
const mediaFilesList = files.filter(file => file.type.startsWith('image/') || file.type.startsWith('video/'));
|
| 220 |
+
const textFilesList = files.filter(file => file.name.toLowerCase().endsWith('.txt'));
|
| 221 |
+
|
| 222 |
+
// Create a map of filename (no extension) to the text file object for quick lookup
|
| 223 |
+
const textFilesMap = new Map<string, File>();
|
| 224 |
+
textFilesList.forEach(f => {
|
| 225 |
+
const baseName = f.name.substring(0, f.name.lastIndexOf('.'));
|
| 226 |
+
textFilesMap.set(baseName.toLowerCase(), f);
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
const newMediaFiles = await Promise.all(mediaFilesList.map(async (file) => {
|
| 230 |
+
const baseName = file.name.substring(0, file.name.lastIndexOf('.'));
|
| 231 |
+
let initialCaption = '';
|
| 232 |
+
|
| 233 |
+
const matchedTxtFile = textFilesMap.get(baseName.toLowerCase());
|
| 234 |
+
if (matchedTxtFile) {
|
| 235 |
+
try {
|
| 236 |
+
initialCaption = await matchedTxtFile.text();
|
| 237 |
+
} catch (e) {
|
| 238 |
+
console.error(`Failed to read caption for ${file.name}`, e);
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
id: `${file.name}-${Math.random()}`,
|
| 244 |
+
file,
|
| 245 |
+
previewUrl: URL.createObjectURL(file),
|
| 246 |
+
caption: initialCaption.trim(),
|
| 247 |
+
status: GenerationStatus.IDLE,
|
| 248 |
+
isSelected: false,
|
| 249 |
+
customInstructions: '',
|
| 250 |
+
comfyStatus: 'idle'
|
| 251 |
+
} as MediaFile;
|
| 252 |
+
}));
|
| 253 |
+
|
| 254 |
setMediaFiles(prev => [...(prev || []), ...newMediaFiles]);
|
| 255 |
}, []);
|
| 256 |
|
|
|
|
| 261 |
updateFile(id, { status: GenerationStatus.CHECKING, errorMessage: undefined });
|
| 262 |
|
| 263 |
try {
|
| 264 |
+
let score = 0;
|
| 265 |
+
if (apiProvider === 'gemini') {
|
| 266 |
+
score = await checkCaptionQuality(fileToProcess.file, fileToProcess.caption, abortControllerRef.current.signal, geminiApiKey, geminiModel);
|
| 267 |
+
} else if (apiProvider === 'grok') {
|
| 268 |
+
score = await checkQualityGrok(grokApiKey, grokModel, fileToProcess.file, fileToProcess.caption, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 269 |
+
} else if (apiProvider === 'openrouter') {
|
| 270 |
+
score = await checkQualityOpenRouter(openRouterApiKey, openRouterModel, fileToProcess.file, fileToProcess.caption, qwenVideoFrameCount, openRouterTemperature, openRouterUseFullVideo, abortControllerRef.current.signal);
|
| 271 |
+
} else {
|
| 272 |
+
score = await checkQualityQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 273 |
+
}
|
| 274 |
|
| 275 |
updateFile(id, { qualityScore: score, status: GenerationStatus.SUCCESS });
|
| 276 |
} catch (err: any) {
|
|
|
|
| 280 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 281 |
}
|
| 282 |
}
|
| 283 |
+
}, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, grokApiKey, grokModel, openRouterApiKey, openRouterModel, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
| 284 |
+
|
| 285 |
+
const handleSelectionChange = useCallback((id: string, isSelected: boolean, shiftKey: boolean) => {
|
| 286 |
+
setMediaFiles(prev => {
|
| 287 |
+
const files = prev || [];
|
| 288 |
+
const currentIndex = files.findIndex(f => f.id === id);
|
| 289 |
+
if (currentIndex === -1) return files;
|
| 290 |
+
|
| 291 |
+
if (shiftKey && lastSelectedIndex !== null) {
|
| 292 |
+
const start = Math.min(lastSelectedIndex, currentIndex);
|
| 293 |
+
const end = Math.max(lastSelectedIndex, currentIndex);
|
| 294 |
+
return files.map((f, idx) => {
|
| 295 |
+
if (idx >= start && idx <= end) {
|
| 296 |
+
return { ...f, isSelected };
|
| 297 |
+
}
|
| 298 |
+
return f;
|
| 299 |
+
});
|
| 300 |
+
} else {
|
| 301 |
+
return files.map(f => (f.id === id ? { ...f, isSelected } : f));
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
setLastSelectedIndex(mediaFiles.findIndex(f => f.id === id));
|
| 305 |
+
}, [mediaFiles, lastSelectedIndex]);
|
| 306 |
|
| 307 |
const handleGenerateCaption = useCallback(async (id: string, itemInstructions?: string) => {
|
| 308 |
const fileToProcess = (mediaFiles || []).find(mf => mf.id === id);
|
|
|
|
| 313 |
const combinedInstructions = `${bulkGenerationInstructions}\n\n${itemInstructions || ''}`.trim();
|
| 314 |
|
| 315 |
try {
|
| 316 |
+
let caption = '';
|
| 317 |
+
if (apiProvider === 'gemini') {
|
| 318 |
+
caption = await generateCaption(fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, abortControllerRef.current.signal, geminiApiKey, geminiModel);
|
| 319 |
+
} else if (apiProvider === 'grok') {
|
| 320 |
+
caption = await generateCaptionGrok(grokApiKey, grokModel, fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 321 |
+
} else if (apiProvider === 'openrouter') {
|
| 322 |
+
caption = await generateCaptionOpenRouter(openRouterApiKey, openRouterModel, fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, qwenVideoFrameCount, openRouterMaxTokens, openRouterTemperature, openRouterUseFullVideo, abortControllerRef.current.signal);
|
| 323 |
+
console.log(`Caption received for ${id}:`, caption);
|
| 324 |
+
} else {
|
| 325 |
+
caption = await generateCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 326 |
+
}
|
| 327 |
|
| 328 |
+
console.log(`Updating file ${id} to SUCCESS status`);
|
| 329 |
updateFile(id, { caption, status: GenerationStatus.SUCCESS });
|
| 330 |
} catch (err: any) {
|
| 331 |
+
console.error(`Error in handleGenerateCaption for ${id}:`, err);
|
| 332 |
if (err.name === 'AbortError' || err.message === 'AbortError') {
|
| 333 |
updateFile(id, { status: GenerationStatus.IDLE, errorMessage: "Stopped by user" });
|
| 334 |
} else {
|
| 335 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 336 |
}
|
| 337 |
}
|
| 338 |
+
}, [mediaFiles, triggerWord, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, grokApiKey, grokModel, openRouterApiKey, openRouterModel, openRouterMaxTokens, openRouterTemperature, openRouterUseFullVideo, bulkGenerationInstructions, isCharacterTaggingEnabled, characterShowName, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
| 339 |
|
| 340 |
const handleRefineCaptionItem = useCallback(async (id: string, itemInstructions?: string) => {
|
| 341 |
const fileToProcess = (mediaFiles || []).find(mf => mf.id === id);
|
|
|
|
| 346 |
const combinedInstructions = `${bulkRefinementInstructions}\n\n${itemInstructions || ''}`.trim();
|
| 347 |
|
| 348 |
try {
|
| 349 |
+
let caption = '';
|
| 350 |
+
if (apiProvider === 'gemini') {
|
| 351 |
+
caption = await refineCaption(fileToProcess.file, fileToProcess.caption, combinedInstructions, abortControllerRef.current.signal, geminiApiKey, geminiModel);
|
| 352 |
+
} else if (apiProvider === 'grok') {
|
| 353 |
+
caption = await refineCaptionGrok(grokApiKey, grokModel, fileToProcess.file, fileToProcess.caption, combinedInstructions, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 354 |
+
} else if (apiProvider === 'openrouter') {
|
| 355 |
+
caption = await refineCaptionOpenRouter(openRouterApiKey, openRouterModel, fileToProcess.file, fileToProcess.caption, combinedInstructions, qwenVideoFrameCount, openRouterMaxTokens, openRouterTemperature, openRouterUseFullVideo, abortControllerRef.current.signal);
|
| 356 |
+
} else {
|
| 357 |
+
caption = await refineCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, combinedInstructions, qwenVideoFrameCount, abortControllerRef.current.signal);
|
| 358 |
+
}
|
| 359 |
|
| 360 |
updateFile(id, { caption, status: GenerationStatus.SUCCESS });
|
| 361 |
} catch (err: any) {
|
|
|
|
| 365 |
updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message });
|
| 366 |
}
|
| 367 |
}
|
| 368 |
+
}, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, grokApiKey, grokModel, openRouterApiKey, openRouterModel, openRouterMaxTokens, bulkRefinementInstructions, hasValidConfig, updateFile, geminiApiKey, geminiModel]);
|
| 369 |
|
| 370 |
// --- QUEUE CONTROLLER ---
|
| 371 |
const runTasksInQueue = async (tasks: (() => Promise<void>)[]) => {
|
| 372 |
+
const signal = abortControllerRef.current.signal;
|
| 373 |
setIsQueueRunning(true);
|
| 374 |
const pool = new Set<Promise<void>>();
|
| 375 |
for (const task of tasks) {
|
| 376 |
+
if (signal.aborted) break;
|
| 377 |
const promise = task();
|
| 378 |
pool.add(promise);
|
| 379 |
promise.finally(() => pool.delete(promise));
|
|
|
|
| 386 |
};
|
| 387 |
|
| 388 |
const handleBulkGenerate = () => {
|
| 389 |
+
if (abortControllerRef.current.signal.aborted) {
|
| 390 |
+
abortControllerRef.current = new AbortController();
|
| 391 |
+
}
|
| 392 |
const tasks = selectedFiles.map(file => () => handleGenerateCaption(file.id, file.customInstructions));
|
| 393 |
if (useRequestQueue) {
|
| 394 |
runTasksInQueue(tasks);
|
|
|
|
| 398 |
};
|
| 399 |
|
| 400 |
const handleBulkRefine = () => {
|
| 401 |
+
if (abortControllerRef.current.signal.aborted) {
|
| 402 |
+
abortControllerRef.current = new AbortController();
|
| 403 |
+
}
|
| 404 |
const tasks = selectedFiles.map(file => () => handleRefineCaptionItem(file.id, file.customInstructions));
|
| 405 |
if (useRequestQueue) {
|
| 406 |
runTasksInQueue(tasks);
|
|
|
|
| 410 |
};
|
| 411 |
|
| 412 |
const handleBulkQualityCheck = () => {
|
| 413 |
+
if (abortControllerRef.current.signal.aborted) {
|
| 414 |
+
abortControllerRef.current = new AbortController();
|
| 415 |
+
}
|
| 416 |
const tasks = selectedFiles.map(file => () => handleCheckQuality(file.id));
|
| 417 |
if (useRequestQueue) {
|
| 418 |
runTasksInQueue(tasks);
|
|
|
|
| 452 |
const remaining = (prev || []).filter(mf => !mf.isSelected);
|
| 453 |
return remaining || [];
|
| 454 |
});
|
| 455 |
+
setLastSelectedIndex(null);
|
| 456 |
}, []);
|
| 457 |
|
| 458 |
const handleStopTasks = () => {
|
| 459 |
abortControllerRef.current.abort();
|
|
|
|
| 460 |
setIsQueueRunning(false);
|
| 461 |
setMediaFiles(prev => (prev || []).map(mf => {
|
| 462 |
if (mf.status === GenerationStatus.GENERATING || mf.status === GenerationStatus.CHECKING) {
|
|
|
|
| 511 |
const downloadQwenSetupScript = () => {
|
| 512 |
const isWin = qwenOsType === 'windows';
|
| 513 |
const content = isWin
|
| 514 |
+
? `@echo off\nSETLOCAL EnableDelayedExpansion\necho [LoRA Caption Assistant] Starting Local Qwen Setup for Windows...\n\n:: Check for Python\npython --version >nul 2>&1\nif %errorlevel% neq 0 (\n echo [ERROR] Python not found! Please install Python 3.10+ from python.org\n pause\n exit /b\n)\n\necho [1/3] Creating Virtual Environment...\npython -m venv venv\nif %errorlevel% neq 0 (\n echo [ERROR] Failed to create venv.\n pause\n exit /b\n)\n\necho [2/3] Activating Environment and Upgrading Pip...\ncall venv\\Scripts\\activate\npython -m pip install --upgrade pip\n\necho [3/3] Installing vLLM and Dependencies...\necho vLLM natively on Windows is Experimental. Using WSL2 is highly recommended.\necho Attempting installation of bitsandbytes and requirements...\npip install bitsandbytes requests\n:: Note: Users often need specific wheels for vLLM on Windows or WSL2.\necho To run vLLM on Windows, please follow the official guide for WSL2.\necho This script sets up the local Python environment for bridging.\npause`
|
| 515 |
: `#!/bin/bash\npython3 -m venv venv\nsource venv/bin/activate\npip install vllm bitsandbytes\necho Setup Complete.`;
|
| 516 |
const filename = isWin ? 'setup_qwen.bat' : 'setup_qwen.sh';
|
| 517 |
const blob = new Blob([content], { type: 'text/plain' });
|
|
|
|
| 523 |
URL.revokeObjectURL(url);
|
| 524 |
};
|
| 525 |
|
| 526 |
+
const downloadBridgeSetupScript = () => {
|
| 527 |
+
const isWin = bridgeOsType === 'windows';
|
| 528 |
+
const content = isWin
|
| 529 |
+
? `@echo off\nSETLOCAL EnableDelayedExpansion\necho [LoRA Caption Assistant] Starting Secure Bridge Setup for Windows...\n\n:: Check for Python\npython --version >nul 2>&1\nif %errorlevel% neq 0 (\n echo [ERROR] Python not found! Please install Python 3.10+ from python.org\n pause\n exit /b\n)\n\necho [1/3] Creating Virtual Environment...\npython -m venv venv\nif %errorlevel% neq 0 (\n echo [ERROR] Failed to create venv.\n pause\n exit /b\n)\n\necho [2/3] Activating Environment...\ncall venv\\Scripts\\activate\n\necho [3/3] Installing Bridge Dependencies...\npip install flask flask-cors requests\nif %errorlevel% neq 0 (\n echo [ERROR] Installation failed.\n pause\n exit /b\n)\n\necho Bridge Setup Complete. You can now download bridge.py and run it using the command shown in the app.\npause`
|
| 530 |
+
: `#!/bin/bash\npython3 -m venv venv\nsource venv/bin/activate\npip install vllm bitsandbytes\necho Setup Complete.`;
|
| 531 |
+
const filename = isWin ? 'setup_bridge.bat' : 'setup_bridge.sh';
|
| 532 |
+
const blob = new Blob([content], { type: 'text/plain' });
|
| 533 |
+
const url = URL.createObjectURL(blob);
|
| 534 |
+
const a = document.createElement('a');
|
| 535 |
+
a.href = url;
|
| 536 |
+
a.download = filename;
|
| 537 |
+
a.click();
|
| 538 |
+
URL.revokeObjectURL(url);
|
| 539 |
+
};
|
| 540 |
+
|
| 541 |
const downloadBridgeScript = () => {
|
| 542 |
const code = `import requests\nfrom flask import Flask, request, Response\nfrom flask_cors import CORS\napp = Flask(__name__)\nCORS(app)\nTARGET = "http://127.0.0.1:8188"\n@app.route('/', defaults={'path': ''}, methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\n@app.route('/<path:path>', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\ndef proxy(path):\n url = f"{TARGET}/{path}"\n headers = {k:v for k,v in request.headers.items() if k.lower() not in ['host', 'origin', 'referer']}\n resp = requests.request(method=request.method, url=url, headers=headers, data=request.get_data(), params=request.args, stream=True)\n return Response(resp.content, resp.status_code, [(n,v) for n,v in resp.headers.items() if n.lower() not in ['content-encoding','content-length','transfer-encoding','connection']])\nif __name__ == '__main__': app.run(port=5000, host='0.0.0.0')`;
|
| 543 |
const blob = new Blob([code], { type: 'text/x-python' });
|
|
|
|
| 612 |
<div>
|
| 613 |
<label className="text-xs font-black text-gray-500 uppercase tracking-widest block mb-4">AI Provider</label>
|
| 614 |
<div className="flex p-1.5 bg-black rounded-2xl border border-gray-800 shadow-inner">
|
| 615 |
+
<button onClick={() => setApiProvider('gemini')} className={`flex-1 py-3 text-[10px] font-black uppercase rounded-xl transition-all ${apiProvider === 'gemini' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>Google Gemini</button>
|
| 616 |
+
<button onClick={() => setApiProvider('grok')} className={`flex-1 py-3 text-[10px] font-black uppercase rounded-xl transition-all ${apiProvider === 'grok' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>xAI Grok</button>
|
| 617 |
+
<button onClick={() => setApiProvider('qwen')} className={`flex-1 py-3 text-[10px] font-black uppercase rounded-xl transition-all ${apiProvider === 'qwen' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>Local Qwen</button>
|
| 618 |
+
<button onClick={() => setApiProvider('openrouter')} className={`flex-1 py-3 text-[10px] font-black uppercase rounded-xl transition-all ${apiProvider === 'openrouter' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>OpenRouter</button>
|
| 619 |
</div>
|
| 620 |
</div>
|
| 621 |
|
| 622 |
+
{apiProvider === 'openrouter' && (
|
| 623 |
+
<div className="bg-blue-500/5 border border-blue-500/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl">
|
| 624 |
+
<div className="space-y-4">
|
| 625 |
+
<div className="flex justify-between items-center">
|
| 626 |
+
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest">OpenRouter Model / URL</label>
|
| 627 |
+
</div>
|
| 628 |
+
<div className="space-y-2">
|
| 629 |
+
<select
|
| 630 |
+
value={OPENROUTER_MODELS.some(m => m.id === openRouterModel) ? openRouterModel : 'custom'}
|
| 631 |
+
onChange={(e) => {
|
| 632 |
+
if (e.target.value === 'custom') {
|
| 633 |
+
setOpenRouterModel('');
|
| 634 |
+
} else {
|
| 635 |
+
setOpenRouterModel(e.target.value);
|
| 636 |
+
}
|
| 637 |
+
}}
|
| 638 |
+
className="w-full p-3 bg-black border border-blue-500/30 rounded-xl text-xs font-bold text-gray-300 shadow-inner focus:ring-1 focus:ring-blue-500 outline-none"
|
| 639 |
+
>
|
| 640 |
+
{OPENROUTER_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
| 641 |
+
<option value="custom">Custom Model ID / URL</option>
|
| 642 |
+
</select>
|
| 643 |
+
{!OPENROUTER_MODELS.some(m => m.id === openRouterModel) && (
|
| 644 |
+
<input
|
| 645 |
+
type="text"
|
| 646 |
+
value={openRouterModel}
|
| 647 |
+
onChange={(e) => setOpenRouterModel(e.target.value)}
|
| 648 |
+
placeholder="e.g. anthropic/claude-3-opus or https://openrouter.ai/..."
|
| 649 |
+
className="w-full p-3 bg-black border border-blue-500/30 rounded-xl text-xs font-mono text-gray-300 shadow-inner focus:ring-1 focus:ring-blue-500 outline-none"
|
| 650 |
+
/>
|
| 651 |
+
)}
|
| 652 |
+
</div>
|
| 653 |
+
</div>
|
| 654 |
+
|
| 655 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 656 |
+
<div className="space-y-4">
|
| 657 |
+
<div className="flex justify-between items-center">
|
| 658 |
+
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest">OpenRouter API Key</label>
|
| 659 |
+
{openRouterApiKey && <span className="flex items-center gap-1.5 text-[9px] font-black uppercase text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full"><CheckCircleIcon className="w-3 h-3"/> Configured</span>}
|
| 660 |
+
</div>
|
| 661 |
+
<div className="relative group">
|
| 662 |
+
<input
|
| 663 |
+
type="password"
|
| 664 |
+
value={openRouterApiKey}
|
| 665 |
+
onChange={(e) => setOpenRouterApiKey(e.target.value)}
|
| 666 |
+
placeholder="sk-or-v1-..."
|
| 667 |
+
className="w-full py-4 px-5 bg-black border border-blue-500/30 rounded-2xl text-xs font-mono shadow-inner focus:ring-1 focus:ring-blue-500 outline-none hover:border-blue-500/60 transition-all"
|
| 668 |
+
/>
|
| 669 |
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-blue-400/50 group-hover:text-blue-400 transition-colors">
|
| 670 |
+
<SparklesIcon className="w-5 h-5" />
|
| 671 |
+
</div>
|
| 672 |
+
</div>
|
| 673 |
+
</div>
|
| 674 |
+
|
| 675 |
+
<div className="space-y-4">
|
| 676 |
+
<div className="flex justify-between items-center">
|
| 677 |
+
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest">Max Tokens</label>
|
| 678 |
+
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">{openRouterMaxTokens}</span>
|
| 679 |
+
</div>
|
| 680 |
+
<div className="relative group">
|
| 681 |
+
<input
|
| 682 |
+
type="number"
|
| 683 |
+
value={openRouterMaxTokens}
|
| 684 |
+
onChange={(e) => setOpenRouterMaxTokens(parseInt(e.target.value) || 4096)}
|
| 685 |
+
min="1"
|
| 686 |
+
max="32768"
|
| 687 |
+
className="w-full py-4 px-5 bg-black border border-blue-500/30 rounded-2xl text-xs font-mono shadow-inner focus:ring-1 focus:ring-blue-500 outline-none hover:border-blue-500/60 transition-all"
|
| 688 |
+
/>
|
| 689 |
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-blue-400/50 group-hover:text-blue-400 transition-colors">
|
| 690 |
+
<WandIcon className="w-5 h-5" />
|
| 691 |
+
</div>
|
| 692 |
+
</div>
|
| 693 |
+
</div>
|
| 694 |
+
</div>
|
| 695 |
+
|
| 696 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 697 |
+
<div className="space-y-4">
|
| 698 |
+
<div className="flex justify-between items-center">
|
| 699 |
+
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest">Temperature</label>
|
| 700 |
+
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">{openRouterTemperature}</span>
|
| 701 |
+
</div>
|
| 702 |
+
<div className="relative group">
|
| 703 |
+
<input
|
| 704 |
+
type="range"
|
| 705 |
+
min="0"
|
| 706 |
+
max="2"
|
| 707 |
+
step="0.1"
|
| 708 |
+
value={openRouterTemperature}
|
| 709 |
+
onChange={(e) => setOpenRouterTemperature(parseFloat(e.target.value))}
|
| 710 |
+
className="w-full h-2 bg-black border border-blue-500/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
| 711 |
+
/>
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
|
| 715 |
+
<div className="space-y-4">
|
| 716 |
+
<div className="flex justify-between items-center">
|
| 717 |
+
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest">Video Mode</label>
|
| 718 |
+
</div>
|
| 719 |
+
<button
|
| 720 |
+
onClick={() => setOpenRouterUseFullVideo(!openRouterUseFullVideo)}
|
| 721 |
+
className={`w-full py-4 px-5 border rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-between ${openRouterUseFullVideo ? 'bg-blue-500/20 border-blue-500 text-blue-400' : 'bg-black border-blue-500/30 text-gray-500 hover:border-blue-500/60'}`}
|
| 722 |
+
>
|
| 723 |
+
{openRouterUseFullVideo ? 'Full Video File' : '8 Frames (Snapshots)'}
|
| 724 |
+
<div className={`w-8 h-4 rounded-full relative transition-colors ${openRouterUseFullVideo ? 'bg-blue-500' : 'bg-gray-700'}`}>
|
| 725 |
+
<div className={`absolute top-1 w-2 h-2 bg-white rounded-full transition-all ${openRouterUseFullVideo ? 'right-1' : 'left-1'}`} />
|
| 726 |
+
</div>
|
| 727 |
+
</button>
|
| 728 |
+
</div>
|
| 729 |
+
</div>
|
| 730 |
+
<p className="text-[10px] text-gray-500 flex items-center gap-1.5 px-1">
|
| 731 |
+
<AlertTriangleIcon className="w-3 h-3 text-blue-400" />
|
| 732 |
+
Get an API key from
|
| 733 |
+
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline font-bold">OpenRouter Keys</a>
|
| 734 |
+
</p>
|
| 735 |
+
</div>
|
| 736 |
+
)}
|
| 737 |
+
|
| 738 |
+
{apiProvider === 'gemini' && (
|
| 739 |
<div className="bg-indigo-500/5 border border-indigo-500/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl">
|
| 740 |
<div className="space-y-4">
|
| 741 |
<div className="flex justify-between items-center">
|
| 742 |
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Gemini Model Version</label>
|
| 743 |
</div>
|
| 744 |
+
<div className="space-y-2">
|
| 745 |
+
<select
|
| 746 |
+
value={GEMINI_MODELS.some(m => m.id === geminiModel) ? geminiModel : 'custom'}
|
| 747 |
+
onChange={(e) => {
|
| 748 |
+
if (e.target.value === 'custom') {
|
| 749 |
+
setGeminiModel('');
|
| 750 |
+
} else {
|
| 751 |
+
setGeminiModel(e.target.value);
|
| 752 |
+
}
|
| 753 |
+
}}
|
| 754 |
+
className="w-full p-3 bg-black border border-indigo-500/30 rounded-xl text-xs font-bold text-gray-300 shadow-inner focus:ring-1 focus:ring-indigo-500 outline-none"
|
| 755 |
+
>
|
| 756 |
+
{GEMINI_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
| 757 |
+
<option value="custom">Custom Model ID</option>
|
| 758 |
+
</select>
|
| 759 |
+
{!GEMINI_MODELS.some(m => m.id === geminiModel) && (
|
| 760 |
+
<input
|
| 761 |
+
type="text"
|
| 762 |
+
value={geminiModel}
|
| 763 |
+
onChange={(e) => setGeminiModel(e.target.value)}
|
| 764 |
+
placeholder="e.g. gemini-2.0-flash-exp"
|
| 765 |
+
className="w-full p-3 bg-black border border-indigo-500/30 rounded-xl text-xs font-mono text-gray-300 shadow-inner focus:ring-1 focus:ring-indigo-500 outline-none"
|
| 766 |
+
/>
|
| 767 |
+
)}
|
| 768 |
+
</div>
|
| 769 |
</div>
|
| 770 |
|
| 771 |
<div className="space-y-4">
|
|
|
|
| 792 |
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline font-bold">Google AI Studio</a>
|
| 793 |
</p>
|
| 794 |
</div>
|
| 795 |
+
)}
|
| 796 |
+
|
| 797 |
+
{apiProvider === 'grok' && (
|
| 798 |
+
<div className="bg-orange-500/5 border border-orange-500/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl">
|
| 799 |
+
<div className="space-y-4">
|
| 800 |
+
<div className="flex justify-between items-center">
|
| 801 |
+
<label className="text-[10px] font-black text-orange-400 uppercase tracking-widest">Grok Model Version</label>
|
| 802 |
+
</div>
|
| 803 |
+
<select
|
| 804 |
+
value={grokModel}
|
| 805 |
+
onChange={(e) => setGrokModel(e.target.value)}
|
| 806 |
+
className="w-full p-3 bg-black border border-orange-500/30 rounded-xl text-xs font-bold text-gray-300 shadow-inner focus:ring-1 focus:ring-orange-500 outline-none"
|
| 807 |
+
>
|
| 808 |
+
{GROK_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
| 809 |
+
</select>
|
| 810 |
+
</div>
|
| 811 |
+
|
| 812 |
+
<div className="space-y-4">
|
| 813 |
+
<div className="flex justify-between items-center">
|
| 814 |
+
<label className="text-[10px] font-black text-orange-400 uppercase tracking-widest">xAI API Key</label>
|
| 815 |
+
{grokApiKey && <span className="flex items-center gap-1.5 text-[9px] font-black uppercase text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full"><CheckCircleIcon className="w-3 h-3"/> Configured</span>}
|
| 816 |
+
</div>
|
| 817 |
+
<div className="relative group">
|
| 818 |
+
<input
|
| 819 |
+
type="password"
|
| 820 |
+
value={grokApiKey}
|
| 821 |
+
onChange={(e) => setGrokApiKey(e.target.value)}
|
| 822 |
+
placeholder="Enter your xAI Grok API key here..."
|
| 823 |
+
className="w-full py-4 px-5 bg-black border border-orange-500/30 rounded-2xl text-xs font-mono shadow-inner focus:ring-1 focus:ring-orange-500 outline-none hover:border-orange-500/60 transition-all"
|
| 824 |
+
/>
|
| 825 |
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-orange-400/50 group-hover:text-orange-400 transition-colors">
|
| 826 |
+
<SparklesIcon className="w-5 h-5" />
|
| 827 |
+
</div>
|
| 828 |
+
</div>
|
| 829 |
+
</div>
|
| 830 |
+
<p className="text-[10px] text-gray-500 flex items-center gap-1.5 px-1">
|
| 831 |
+
<AlertTriangleIcon className="w-3 h-3 text-orange-400" />
|
| 832 |
+
Get an API key from
|
| 833 |
+
<a href="https://console.x.ai/" target="_blank" rel="noopener noreferrer" className="text-orange-400 hover:underline font-bold">xAI Console</a>
|
| 834 |
+
</p>
|
| 835 |
+
</div>
|
| 836 |
+
)}
|
| 837 |
+
|
| 838 |
+
{apiProvider === 'qwen' && (
|
| 839 |
<div className="bg-gray-950 p-6 rounded-3xl border border-gray-800 space-y-6 animate-slide-down shadow-xl">
|
| 840 |
<div className="flex justify-between items-center mb-2">
|
| 841 |
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Local Model Configuration</label>
|
|
|
|
| 861 |
</div>
|
| 862 |
<div className="space-y-1">
|
| 863 |
<label className="text-[9px] font-black text-gray-700 uppercase">Virtual Model Name (Served Name)</label>
|
| 864 |
+
<input type="text" value={virtualModelName} onChange={e => setVirtualModelName(e.target.value)} placeholder="org/model-id..." className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" />
|
| 865 |
</div>
|
| 866 |
</div>
|
| 867 |
) : useCustomQwenModel ? (
|
|
|
|
| 903 |
</label>
|
| 904 |
</div>
|
| 905 |
|
| 906 |
+
<button onClick={downloadQwenSetupScript} className="w-full py-3 bg-green-700 hover:bg-green-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download {qwenOsType === 'windows' ? 'setup_qwen.bat' : 'setup_qwen.sh'}</button>
|
| 907 |
|
| 908 |
<div className="space-y-2">
|
| 909 |
<label className="text-[9px] font-black text-gray-700 uppercase">Local Start Command:</label>
|
|
|
|
| 1061 |
<span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">First-time Setup (Include VENV & Pip Install)</span>
|
| 1062 |
</label>
|
| 1063 |
<div className="flex gap-4">
|
| 1064 |
+
<button onClick={downloadBridgeSetupScript} className="flex-1 py-3 bg-indigo-700 hover:bg-indigo-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download {bridgeOsType === 'windows' ? 'setup_bridge.bat' : 'setup_bridge.sh'}</button>
|
| 1065 |
<button onClick={downloadBridgeScript} className="flex-1 py-3 bg-orange-700 hover:bg-orange-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download Bridge.py</button>
|
| 1066 |
</div>
|
| 1067 |
</div>
|
|
|
|
| 1160 |
onPreview={handleComfyPreview}
|
| 1161 |
onCaptionChange={(id, cap) => updateFile(id, { caption: cap })}
|
| 1162 |
onCustomInstructionsChange={(id, ins) => updateFile(id, { customInstructions: ins })}
|
| 1163 |
+
onSelectionChange={handleSelectionChange}
|
| 1164 |
onOpenPreviewModal={setActivePreviewId}
|
| 1165 |
/>
|
| 1166 |
))}
|
|
|
|
| 1179 |
);
|
| 1180 |
};
|
| 1181 |
|
| 1182 |
+
export default App;
|
components/MediaItem.tsx
CHANGED
|
@@ -14,7 +14,7 @@ interface MediaItemProps {
|
|
| 14 |
onPreview: (id: string) => void;
|
| 15 |
onCaptionChange: (id:string, caption: string) => void;
|
| 16 |
onCustomInstructionsChange: (id: string, instructions: string) => void;
|
| 17 |
-
onSelectionChange: (id: string, isSelected: boolean) => void;
|
| 18 |
onOpenPreviewModal: (id: string) => void;
|
| 19 |
}
|
| 20 |
|
|
@@ -84,13 +84,20 @@ const MediaItem: React.FC<MediaItemProps> = ({
|
|
| 84 |
);
|
| 85 |
};
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
return (
|
| 88 |
<div className={`bg-gray-800 rounded-lg overflow-hidden border-2 transition-all ${getStatusColor()}`}>
|
| 89 |
<div className="relative p-2 space-y-2">
|
| 90 |
<input
|
| 91 |
type="checkbox"
|
| 92 |
checked={item.isSelected}
|
| 93 |
-
|
|
|
|
| 94 |
className="absolute top-4 left-4 h-6 w-6 bg-gray-900/80 backdrop-blur-sm border-gray-600 text-indigo-500 rounded focus:ring-indigo-600 z-10 cursor-pointer shadow-lg"
|
| 95 |
/>
|
| 96 |
{item.qualityScore !== undefined && (
|
|
|
|
| 14 |
onPreview: (id: string) => void;
|
| 15 |
onCaptionChange: (id:string, caption: string) => void;
|
| 16 |
onCustomInstructionsChange: (id: string, instructions: string) => void;
|
| 17 |
+
onSelectionChange: (id: string, isSelected: boolean, shiftKey: boolean) => void;
|
| 18 |
onOpenPreviewModal: (id: string) => void;
|
| 19 |
}
|
| 20 |
|
|
|
|
| 84 |
);
|
| 85 |
};
|
| 86 |
|
| 87 |
+
const handleCheckboxClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
| 88 |
+
// We use onClick to capture the shiftKey, but we need to be careful with the checked state
|
| 89 |
+
// Since it's a controlled component, we toggle the current state
|
| 90 |
+
onSelectionChange(item.id, !item.isSelected, e.shiftKey);
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
return (
|
| 94 |
<div className={`bg-gray-800 rounded-lg overflow-hidden border-2 transition-all ${getStatusColor()}`}>
|
| 95 |
<div className="relative p-2 space-y-2">
|
| 96 |
<input
|
| 97 |
type="checkbox"
|
| 98 |
checked={item.isSelected}
|
| 99 |
+
onClick={handleCheckboxClick}
|
| 100 |
+
onChange={() => {}} // Controlled component dummy
|
| 101 |
className="absolute top-4 left-4 h-6 w-6 bg-gray-900/80 backdrop-blur-sm border-gray-600 text-indigo-500 rounded focus:ring-indigo-600 z-10 cursor-pointer shadow-lg"
|
| 102 |
/>
|
| 103 |
{item.qualityScore !== undefined && (
|
index.html
CHANGED
|
@@ -19,7 +19,6 @@
|
|
| 19 |
}
|
| 20 |
}
|
| 21 |
</script>
|
| 22 |
-
<link rel="stylesheet" href="/index.css">
|
| 23 |
</head>
|
| 24 |
<body class="bg-gray-900 text-gray-100">
|
| 25 |
<div id="root"></div>
|
|
|
|
| 19 |
}
|
| 20 |
}
|
| 21 |
</script>
|
|
|
|
| 22 |
</head>
|
| 23 |
<body class="bg-gray-900 text-gray-100">
|
| 24 |
<div id="root"></div>
|
package.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
-
"preview": "vite preview"
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@google/genai": "^1.30.0",
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"lint": "tsc --noEmit"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
"@google/genai": "^1.30.0",
|
services/geminiService.ts
CHANGED
|
@@ -26,14 +26,27 @@ const withRetry = async <T>(
|
|
| 26 |
}
|
| 27 |
};
|
| 28 |
|
| 29 |
-
const fileToGenerativePart = async (file: File) => {
|
| 30 |
-
const base64EncodedDataPromise = new Promise<string>((resolve) => {
|
| 31 |
const reader = new FileReader();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
reader.onloadend = () => {
|
|
|
|
| 33 |
if (typeof reader.result === 'string') {
|
| 34 |
resolve(reader.result.split(',')[1]);
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
reader.readAsDataURL(file);
|
| 38 |
});
|
| 39 |
return {
|
|
@@ -72,13 +85,13 @@ export const generateCaption = async (
|
|
| 72 |
characterShowName?: string,
|
| 73 |
signal?: AbortSignal,
|
| 74 |
apiKeyOverride?: string,
|
| 75 |
-
model: string = 'gemini-3-pro-preview'
|
| 76 |
): Promise<string> => {
|
| 77 |
-
const apiKey = apiKeyOverride || process.env.
|
| 78 |
if (!apiKey) throw new Error("API Key is missing. Please enter your Gemini API key in the Global Settings.");
|
| 79 |
|
| 80 |
const ai = new GoogleGenAI({ apiKey });
|
| 81 |
-
const imagePart = await fileToGenerativePart(file);
|
| 82 |
const prompt = constructPrompt(triggerWord, customInstructions, isCharacterTaggingEnabled, characterShowName);
|
| 83 |
|
| 84 |
const apiCall = () => ai.models.generateContent({
|
|
@@ -103,13 +116,13 @@ export const refineCaption = async (
|
|
| 103 |
refinementInstructions: string,
|
| 104 |
signal?: AbortSignal,
|
| 105 |
apiKeyOverride?: string,
|
| 106 |
-
model: string = 'gemini-3-pro-preview'
|
| 107 |
): Promise<string> => {
|
| 108 |
-
const apiKey = apiKeyOverride || process.env.
|
| 109 |
if (!apiKey) throw new Error("API Key is missing.");
|
| 110 |
|
| 111 |
const ai = new GoogleGenAI({ apiKey });
|
| 112 |
-
const imagePart = await fileToGenerativePart(file);
|
| 113 |
const prompt = `You are an expert editor for LoRA training data.
|
| 114 |
Refine the provided caption based on the visual information and the user's refinement instructions.
|
| 115 |
Maintain the continuous paragraph format and ensure the trigger word is preserved.
|
|
@@ -139,13 +152,13 @@ export const checkCaptionQuality = async (
|
|
| 139 |
caption: string,
|
| 140 |
signal?: AbortSignal,
|
| 141 |
apiKeyOverride?: string,
|
| 142 |
-
model: string = 'gemini-3-pro-preview'
|
| 143 |
): Promise<number> => {
|
| 144 |
-
const apiKey = apiKeyOverride || process.env.
|
| 145 |
if (!apiKey) throw new Error("API Key is missing.");
|
| 146 |
|
| 147 |
const ai = new GoogleGenAI({ apiKey });
|
| 148 |
-
const imagePart = await fileToGenerativePart(file);
|
| 149 |
const prompt = `Evaluate the following caption for accuracy and detail based on the image. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 150 |
|
| 151 |
try {
|
|
|
|
| 26 |
}
|
| 27 |
};
|
| 28 |
|
| 29 |
+
const fileToGenerativePart = async (file: File, signal?: AbortSignal) => {
|
| 30 |
+
const base64EncodedDataPromise = new Promise<string>((resolve, reject) => {
|
| 31 |
const reader = new FileReader();
|
| 32 |
+
const onAbort = () => {
|
| 33 |
+
reader.abort();
|
| 34 |
+
reject(new Error("AbortError"));
|
| 35 |
+
};
|
| 36 |
+
if (signal) signal.addEventListener('abort', onAbort);
|
| 37 |
+
|
| 38 |
reader.onloadend = () => {
|
| 39 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 40 |
if (typeof reader.result === 'string') {
|
| 41 |
resolve(reader.result.split(',')[1]);
|
| 42 |
+
} else {
|
| 43 |
+
reject(new Error("Failed to read file"));
|
| 44 |
}
|
| 45 |
};
|
| 46 |
+
reader.onerror = () => {
|
| 47 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 48 |
+
reject(new Error("FileReader error"));
|
| 49 |
+
};
|
| 50 |
reader.readAsDataURL(file);
|
| 51 |
});
|
| 52 |
return {
|
|
|
|
| 85 |
characterShowName?: string,
|
| 86 |
signal?: AbortSignal,
|
| 87 |
apiKeyOverride?: string,
|
| 88 |
+
model: string = 'gemini-3.1-pro-preview'
|
| 89 |
): Promise<string> => {
|
| 90 |
+
const apiKey = apiKeyOverride || process.env.GEMINI_API_KEY;
|
| 91 |
if (!apiKey) throw new Error("API Key is missing. Please enter your Gemini API key in the Global Settings.");
|
| 92 |
|
| 93 |
const ai = new GoogleGenAI({ apiKey });
|
| 94 |
+
const imagePart = await fileToGenerativePart(file, signal);
|
| 95 |
const prompt = constructPrompt(triggerWord, customInstructions, isCharacterTaggingEnabled, characterShowName);
|
| 96 |
|
| 97 |
const apiCall = () => ai.models.generateContent({
|
|
|
|
| 116 |
refinementInstructions: string,
|
| 117 |
signal?: AbortSignal,
|
| 118 |
apiKeyOverride?: string,
|
| 119 |
+
model: string = 'gemini-3.1-pro-preview'
|
| 120 |
): Promise<string> => {
|
| 121 |
+
const apiKey = apiKeyOverride || process.env.GEMINI_API_KEY;
|
| 122 |
if (!apiKey) throw new Error("API Key is missing.");
|
| 123 |
|
| 124 |
const ai = new GoogleGenAI({ apiKey });
|
| 125 |
+
const imagePart = await fileToGenerativePart(file, signal);
|
| 126 |
const prompt = `You are an expert editor for LoRA training data.
|
| 127 |
Refine the provided caption based on the visual information and the user's refinement instructions.
|
| 128 |
Maintain the continuous paragraph format and ensure the trigger word is preserved.
|
|
|
|
| 152 |
caption: string,
|
| 153 |
signal?: AbortSignal,
|
| 154 |
apiKeyOverride?: string,
|
| 155 |
+
model: string = 'gemini-3.1-pro-preview'
|
| 156 |
): Promise<number> => {
|
| 157 |
+
const apiKey = apiKeyOverride || process.env.GEMINI_API_KEY;
|
| 158 |
if (!apiKey) throw new Error("API Key is missing.");
|
| 159 |
|
| 160 |
const ai = new GoogleGenAI({ apiKey });
|
| 161 |
+
const imagePart = await fileToGenerativePart(file, signal);
|
| 162 |
const prompt = `Evaluate the following caption for accuracy and detail based on the image. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 163 |
|
| 164 |
try {
|
services/grokService.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
/**
|
| 3 |
+
* Service for interacting with xAI Grok via OpenAI-compatible vision endpoints.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const fileToBase64 = (file: File): Promise<string> => {
|
| 7 |
+
return new Promise((resolve, reject) => {
|
| 8 |
+
const reader = new FileReader();
|
| 9 |
+
reader.readAsDataURL(file);
|
| 10 |
+
reader.onload = () => {
|
| 11 |
+
if (typeof reader.result === 'string') {
|
| 12 |
+
resolve(reader.result);
|
| 13 |
+
} else {
|
| 14 |
+
reject(new Error('Failed to convert file to base64'));
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
reader.onerror = error => reject(error);
|
| 18 |
+
});
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const extractFramesFromVideo = async (videoFile: File, numberOfFrames: number): Promise<string[]> => {
|
| 22 |
+
return new Promise((resolve, reject) => {
|
| 23 |
+
const video = document.createElement('video');
|
| 24 |
+
video.preload = 'metadata';
|
| 25 |
+
video.muted = true;
|
| 26 |
+
video.playsInline = true;
|
| 27 |
+
const url = URL.createObjectURL(videoFile);
|
| 28 |
+
const frames: string[] = [];
|
| 29 |
+
const timeout = setTimeout(() => {
|
| 30 |
+
URL.revokeObjectURL(url);
|
| 31 |
+
video.src = "";
|
| 32 |
+
reject(new Error("Video processing timed out"));
|
| 33 |
+
}, 60000);
|
| 34 |
+
|
| 35 |
+
video.onloadeddata = async () => {
|
| 36 |
+
const duration = video.duration;
|
| 37 |
+
const canvas = document.createElement('canvas');
|
| 38 |
+
const ctx = canvas.getContext('2d');
|
| 39 |
+
if (!ctx) {
|
| 40 |
+
clearTimeout(timeout);
|
| 41 |
+
URL.revokeObjectURL(url);
|
| 42 |
+
reject(new Error("Could not create canvas context"));
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
canvas.width = video.videoWidth;
|
| 46 |
+
canvas.height = video.videoHeight;
|
| 47 |
+
const step = duration / numberOfFrames;
|
| 48 |
+
try {
|
| 49 |
+
for (let i = 0; i < numberOfFrames; i++) {
|
| 50 |
+
const time = (step * i) + (step / 2);
|
| 51 |
+
await new Promise<void>((frameResolve) => {
|
| 52 |
+
const onSeeked = () => {
|
| 53 |
+
video.removeEventListener('seeked', onSeeked);
|
| 54 |
+
frameResolve();
|
| 55 |
+
};
|
| 56 |
+
video.addEventListener('seeked', onSeeked);
|
| 57 |
+
video.currentTime = Math.min(time, duration - 0.1);
|
| 58 |
+
});
|
| 59 |
+
ctx.drawImage(video, 0, 0);
|
| 60 |
+
frames.push(canvas.toDataURL('image/jpeg', 0.8));
|
| 61 |
+
}
|
| 62 |
+
clearTimeout(timeout);
|
| 63 |
+
URL.revokeObjectURL(url);
|
| 64 |
+
video.src = "";
|
| 65 |
+
resolve(frames);
|
| 66 |
+
} catch (e) {
|
| 67 |
+
clearTimeout(timeout);
|
| 68 |
+
URL.revokeObjectURL(url);
|
| 69 |
+
reject(e);
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
video.onerror = () => {
|
| 73 |
+
clearTimeout(timeout);
|
| 74 |
+
URL.revokeObjectURL(url);
|
| 75 |
+
reject(new Error("Failed to load video file"));
|
| 76 |
+
};
|
| 77 |
+
video.src = url;
|
| 78 |
+
});
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const constructPrompt = (
|
| 82 |
+
triggerWord: string,
|
| 83 |
+
customInstructions?: string,
|
| 84 |
+
isCharacterTaggingEnabled?: boolean,
|
| 85 |
+
characterShowName?: string
|
| 86 |
+
): string => {
|
| 87 |
+
let basePrompt = `You are an expert captioner for AI model training data. Your task is to describe the provided image/video in detail for a style LoRA. Follow these rules strictly:
|
| 88 |
+
1. Start the caption with the trigger word: "${triggerWord}".
|
| 89 |
+
2. Describe EVERYTHING visible: characters, clothing, actions, background, objects, lighting, and camera angle.
|
| 90 |
+
3. Be objective and factual.
|
| 91 |
+
4. DO NOT mention the art style, "anime", "cartoon", "illustration", "2d", or "animation".
|
| 92 |
+
5. Write the description as a single, continuous paragraph.`;
|
| 93 |
+
|
| 94 |
+
if (isCharacterTaggingEnabled && characterShowName && characterShowName.trim() !== '') {
|
| 95 |
+
basePrompt += `\n6. After the description, identify any characters from the show "${characterShowName}" and append their tags to the very end of the caption, separated by commas. The format for each tag must be "char_[charactername]" (e.g., ", char_simon, char_kamina"). If no characters are recognized, add no tags.`;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (customInstructions) {
|
| 99 |
+
return `${basePrompt}\n\nIMPORTANT USER INSTRUCTIONS:\n${customInstructions}`;
|
| 100 |
+
}
|
| 101 |
+
return basePrompt;
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
export const generateCaptionGrok = async (
|
| 105 |
+
apiKey: string,
|
| 106 |
+
model: string,
|
| 107 |
+
file: File,
|
| 108 |
+
triggerWord: string,
|
| 109 |
+
customInstructions?: string,
|
| 110 |
+
isCharacterTaggingEnabled?: boolean,
|
| 111 |
+
characterShowName?: string,
|
| 112 |
+
videoFrameCount: number = 8,
|
| 113 |
+
signal?: AbortSignal
|
| 114 |
+
): Promise<string> => {
|
| 115 |
+
if (!apiKey) throw new Error("xAI API Key is required for Grok.");
|
| 116 |
+
const endpoint = 'https://api.x.ai/v1/chat/completions';
|
| 117 |
+
const prompt = constructPrompt(triggerWord, customInstructions, isCharacterTaggingEnabled, characterShowName);
|
| 118 |
+
|
| 119 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 120 |
+
if (file.type.startsWith('video/')) {
|
| 121 |
+
if (model === 'grok-imagine-video') {
|
| 122 |
+
const base64Video = await fileToBase64(file);
|
| 123 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 124 |
+
} else {
|
| 125 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount);
|
| 126 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 127 |
+
}
|
| 128 |
+
} else {
|
| 129 |
+
const base64Image = await fileToBase64(file);
|
| 130 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const payload = {
|
| 134 |
+
model: model || 'grok-2-vision-1212',
|
| 135 |
+
messages: [{ role: "user", content: contentParts }],
|
| 136 |
+
max_tokens: 1000,
|
| 137 |
+
temperature: 0.2
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const response = await fetch(endpoint, {
|
| 141 |
+
method: "POST",
|
| 142 |
+
headers: {
|
| 143 |
+
"Content-Type": "application/json",
|
| 144 |
+
"Authorization": `Bearer ${apiKey}`
|
| 145 |
+
},
|
| 146 |
+
body: JSON.stringify(payload),
|
| 147 |
+
signal
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
if (!response.ok) {
|
| 151 |
+
let errorMessage = response.statusText;
|
| 152 |
+
try {
|
| 153 |
+
const errData = await response.json();
|
| 154 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 155 |
+
} catch (e) {
|
| 156 |
+
// If not JSON, try text
|
| 157 |
+
const errText = await response.text().catch(() => "");
|
| 158 |
+
if (errText) errorMessage = errText;
|
| 159 |
+
}
|
| 160 |
+
throw new Error(`Grok API Error (${response.status}): ${errorMessage}`);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const data = await response.json();
|
| 164 |
+
return data.choices?.[0]?.message?.content?.trim() || "";
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
export const refineCaptionGrok = async (
|
| 168 |
+
apiKey: string,
|
| 169 |
+
model: string,
|
| 170 |
+
file: File,
|
| 171 |
+
currentCaption: string,
|
| 172 |
+
refinementInstructions: string,
|
| 173 |
+
videoFrameCount: number = 4,
|
| 174 |
+
signal?: AbortSignal
|
| 175 |
+
): Promise<string> => {
|
| 176 |
+
if (!apiKey) throw new Error("xAI API Key is required for Grok.");
|
| 177 |
+
const endpoint = 'https://api.x.ai/v1/chat/completions';
|
| 178 |
+
const prompt = `Refine the following caption based on the visual information and the instructions. Output ONLY the refined text.
|
| 179 |
+
CURRENT CAPTION: "${currentCaption}"
|
| 180 |
+
INSTRUCTIONS: "${refinementInstructions}"`;
|
| 181 |
+
|
| 182 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 183 |
+
if (file.type.startsWith('video/')) {
|
| 184 |
+
if (model === 'grok-imagine-video') {
|
| 185 |
+
const base64Video = await fileToBase64(file);
|
| 186 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 187 |
+
} else {
|
| 188 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount);
|
| 189 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 190 |
+
}
|
| 191 |
+
} else {
|
| 192 |
+
const base64Image = await fileToBase64(file);
|
| 193 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const payload = {
|
| 197 |
+
model: model || 'grok-2-vision-1212',
|
| 198 |
+
messages: [{ role: "user", content: contentParts }],
|
| 199 |
+
max_tokens: 1000,
|
| 200 |
+
temperature: 0.2
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
+
const response = await fetch(endpoint, {
|
| 204 |
+
method: "POST",
|
| 205 |
+
headers: {
|
| 206 |
+
"Content-Type": "application/json",
|
| 207 |
+
"Authorization": `Bearer ${apiKey}`
|
| 208 |
+
},
|
| 209 |
+
body: JSON.stringify(payload),
|
| 210 |
+
signal
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
if (!response.ok) {
|
| 214 |
+
let errorMessage = response.statusText;
|
| 215 |
+
try {
|
| 216 |
+
const errData = await response.json();
|
| 217 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 218 |
+
} catch (e) {
|
| 219 |
+
const errText = await response.text().catch(() => "");
|
| 220 |
+
if (errText) errorMessage = errText;
|
| 221 |
+
}
|
| 222 |
+
throw new Error(`Grok API Error (${response.status}): ${errorMessage}`);
|
| 223 |
+
}
|
| 224 |
+
const data = await response.json();
|
| 225 |
+
return data.choices?.[0]?.message?.content?.trim() || "";
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
export const checkQualityGrok = async (
|
| 229 |
+
apiKey: string,
|
| 230 |
+
model: string,
|
| 231 |
+
file: File,
|
| 232 |
+
caption: string,
|
| 233 |
+
videoFrameCount: number = 4,
|
| 234 |
+
signal?: AbortSignal
|
| 235 |
+
): Promise<number> => {
|
| 236 |
+
if (!apiKey) throw new Error("xAI API Key is required for Grok.");
|
| 237 |
+
const endpoint = 'https://api.x.ai/v1/chat/completions';
|
| 238 |
+
const prompt = `Evaluate the caption quality. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 239 |
+
|
| 240 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 241 |
+
if (file.type.startsWith('video/')) {
|
| 242 |
+
if (model === 'grok-imagine-video') {
|
| 243 |
+
const base64Video = await fileToBase64(file);
|
| 244 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 245 |
+
} else {
|
| 246 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount);
|
| 247 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 248 |
+
}
|
| 249 |
+
} else {
|
| 250 |
+
const base64Image = await fileToBase64(file);
|
| 251 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const payload = {
|
| 255 |
+
model: model || 'grok-2-vision-1212',
|
| 256 |
+
messages: [{ role: "user", content: contentParts }],
|
| 257 |
+
max_tokens: 10,
|
| 258 |
+
temperature: 0.1
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
const response = await fetch(endpoint, {
|
| 262 |
+
method: "POST",
|
| 263 |
+
headers: {
|
| 264 |
+
"Content-Type": "application/json",
|
| 265 |
+
"Authorization": `Bearer ${apiKey}`
|
| 266 |
+
},
|
| 267 |
+
body: JSON.stringify(payload),
|
| 268 |
+
signal
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
if (!response.ok) {
|
| 272 |
+
let errorMessage = response.statusText;
|
| 273 |
+
try {
|
| 274 |
+
const errData = await response.json();
|
| 275 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 276 |
+
} catch (e) {
|
| 277 |
+
const errText = await response.text().catch(() => "");
|
| 278 |
+
if (errText) errorMessage = errText;
|
| 279 |
+
}
|
| 280 |
+
throw new Error(`Grok API Error (${response.status}): ${errorMessage}`);
|
| 281 |
+
}
|
| 282 |
+
const data = await response.json();
|
| 283 |
+
const text = data.choices?.[0]?.message?.content?.trim();
|
| 284 |
+
return parseInt(text?.match(/\d+/)?.[0] || '0', 10);
|
| 285 |
+
};
|
services/openRouterService.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
/**
|
| 3 |
+
* Service for interacting with OpenRouter API.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const fileToBase64 = (file: File): Promise<string> => {
|
| 7 |
+
return new Promise((resolve, reject) => {
|
| 8 |
+
const reader = new FileReader();
|
| 9 |
+
reader.readAsDataURL(file);
|
| 10 |
+
reader.onload = () => {
|
| 11 |
+
if (typeof reader.result === 'string') {
|
| 12 |
+
resolve(reader.result);
|
| 13 |
+
} else {
|
| 14 |
+
reject(new Error('Failed to convert file to base64'));
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
reader.onerror = error => reject(error);
|
| 18 |
+
});
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const extractFramesFromVideo = async (videoFile: File, numberOfFrames: number, signal?: AbortSignal): Promise<string[]> => {
|
| 22 |
+
return new Promise((resolve, reject) => {
|
| 23 |
+
const video = document.createElement('video');
|
| 24 |
+
video.preload = 'metadata';
|
| 25 |
+
video.muted = true;
|
| 26 |
+
video.playsInline = true;
|
| 27 |
+
const url = URL.createObjectURL(videoFile);
|
| 28 |
+
const frames: string[] = [];
|
| 29 |
+
|
| 30 |
+
const onAbort = () => {
|
| 31 |
+
URL.revokeObjectURL(url);
|
| 32 |
+
video.src = "";
|
| 33 |
+
reject(new Error("AbortError"));
|
| 34 |
+
};
|
| 35 |
+
if (signal) signal.addEventListener('abort', onAbort);
|
| 36 |
+
|
| 37 |
+
const timeout = setTimeout(() => {
|
| 38 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 39 |
+
URL.revokeObjectURL(url);
|
| 40 |
+
video.src = "";
|
| 41 |
+
reject(new Error("Video processing timed out"));
|
| 42 |
+
}, 60000);
|
| 43 |
+
|
| 44 |
+
video.onloadeddata = async () => {
|
| 45 |
+
const duration = video.duration;
|
| 46 |
+
const canvas = document.createElement('canvas');
|
| 47 |
+
const ctx = canvas.getContext('2d');
|
| 48 |
+
if (!ctx) {
|
| 49 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 50 |
+
clearTimeout(timeout);
|
| 51 |
+
URL.revokeObjectURL(url);
|
| 52 |
+
reject(new Error("Could not create canvas context"));
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
canvas.width = video.videoWidth;
|
| 56 |
+
canvas.height = video.videoHeight;
|
| 57 |
+
const step = duration / numberOfFrames;
|
| 58 |
+
try {
|
| 59 |
+
for (let i = 0; i < numberOfFrames; i++) {
|
| 60 |
+
if (signal?.aborted) throw new Error("AbortError");
|
| 61 |
+
const time = (step * i) + (step / 2);
|
| 62 |
+
await new Promise<void>((frameResolve) => {
|
| 63 |
+
const onSeeked = () => {
|
| 64 |
+
video.removeEventListener('seeked', onSeeked);
|
| 65 |
+
frameResolve();
|
| 66 |
+
};
|
| 67 |
+
video.addEventListener('seeked', onSeeked);
|
| 68 |
+
video.currentTime = Math.min(time, duration - 0.1);
|
| 69 |
+
});
|
| 70 |
+
ctx.drawImage(video, 0, 0);
|
| 71 |
+
frames.push(canvas.toDataURL('image/jpeg', 0.8));
|
| 72 |
+
}
|
| 73 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 74 |
+
clearTimeout(timeout);
|
| 75 |
+
URL.revokeObjectURL(url);
|
| 76 |
+
video.src = "";
|
| 77 |
+
resolve(frames);
|
| 78 |
+
} catch (e) {
|
| 79 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 80 |
+
clearTimeout(timeout);
|
| 81 |
+
URL.revokeObjectURL(url);
|
| 82 |
+
reject(e);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
video.onerror = () => {
|
| 86 |
+
if (signal) signal.removeEventListener('abort', onAbort);
|
| 87 |
+
clearTimeout(timeout);
|
| 88 |
+
URL.revokeObjectURL(url);
|
| 89 |
+
reject(new Error("Failed to load video file"));
|
| 90 |
+
};
|
| 91 |
+
video.src = url;
|
| 92 |
+
});
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const constructPrompt = (
|
| 96 |
+
triggerWord: string,
|
| 97 |
+
customInstructions?: string,
|
| 98 |
+
isCharacterTaggingEnabled?: boolean,
|
| 99 |
+
characterShowName?: string
|
| 100 |
+
): string => {
|
| 101 |
+
let basePrompt = `You are an expert captioner for AI model training data. Your task is to describe the provided image/video in detail for a style LoRA. Follow these rules strictly:
|
| 102 |
+
1. Start the caption with the trigger word: "${triggerWord}".
|
| 103 |
+
2. Describe EVERYTHING visible: characters, clothing, actions, background, objects, lighting, and camera angle.
|
| 104 |
+
3. Be objective and factual.
|
| 105 |
+
4. DO NOT mention art styles or generic animation terms like "anime" or "cartoon".
|
| 106 |
+
5. Write as a single, continuous paragraph.`;
|
| 107 |
+
|
| 108 |
+
if (isCharacterTaggingEnabled && characterShowName && characterShowName.trim() !== '') {
|
| 109 |
+
basePrompt += `\n6. Identify characters from the show/series "${characterShowName}" and append tags at the end of the caption, separated by commas. The format for each tag must be "char_[charactername]" (e.g., ", char_simon, char_kamina"). If no characters are recognized, do not add tags.`;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if (customInstructions) {
|
| 113 |
+
return `${basePrompt}\n\nAdditional instructions: ${customInstructions}`;
|
| 114 |
+
}
|
| 115 |
+
return basePrompt;
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
export const generateCaptionOpenRouter = async (
|
| 119 |
+
apiKey: string,
|
| 120 |
+
model: string,
|
| 121 |
+
file: File,
|
| 122 |
+
triggerWord: string,
|
| 123 |
+
customInstructions?: string,
|
| 124 |
+
isCharacterTaggingEnabled?: boolean,
|
| 125 |
+
characterShowName?: string,
|
| 126 |
+
videoFrameCount: number = 8,
|
| 127 |
+
maxTokens: number = 4096,
|
| 128 |
+
temperature: number = 0.7,
|
| 129 |
+
useFullVideo: boolean = false,
|
| 130 |
+
signal?: AbortSignal
|
| 131 |
+
): Promise<string> => {
|
| 132 |
+
if (!apiKey) throw new Error("OpenRouter API Key is required.");
|
| 133 |
+
const endpoint = 'https://openrouter.ai/api/v1/chat/completions';
|
| 134 |
+
const prompt = constructPrompt(triggerWord, customInstructions, isCharacterTaggingEnabled, characterShowName);
|
| 135 |
+
|
| 136 |
+
// Extract model ID from URL if provided
|
| 137 |
+
let modelId = model.includes('openrouter.ai/') ? model.split('openrouter.ai/').pop() || '' : model;
|
| 138 |
+
// Handle /models/ prefix if it exists in the URL
|
| 139 |
+
if (modelId.startsWith('models/')) {
|
| 140 |
+
modelId = modelId.replace('models/', '');
|
| 141 |
+
}
|
| 142 |
+
// Remove any trailing slashes or query params
|
| 143 |
+
modelId = modelId.split('?')[0].replace(/\/+$/, '');
|
| 144 |
+
|
| 145 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 146 |
+
if (file.type.startsWith('video/')) {
|
| 147 |
+
if (useFullVideo) {
|
| 148 |
+
const base64Video = await fileToBase64(file);
|
| 149 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 150 |
+
} else {
|
| 151 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount, signal);
|
| 152 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 153 |
+
}
|
| 154 |
+
} else {
|
| 155 |
+
const base64Image = await fileToBase64(file);
|
| 156 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
const payload = {
|
| 160 |
+
model: modelId || 'openai/gpt-4o-mini',
|
| 161 |
+
messages: [{ role: "user", content: contentParts }],
|
| 162 |
+
max_tokens: maxTokens,
|
| 163 |
+
temperature: temperature
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const response = await fetch(endpoint, {
|
| 167 |
+
method: "POST",
|
| 168 |
+
headers: {
|
| 169 |
+
"Content-Type": "application/json",
|
| 170 |
+
"Authorization": `Bearer ${apiKey}`,
|
| 171 |
+
"HTTP-Referer": window.location.origin,
|
| 172 |
+
"X-Title": "LoRA Caption Assistant"
|
| 173 |
+
},
|
| 174 |
+
body: JSON.stringify(payload),
|
| 175 |
+
signal
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
if (!response.ok) {
|
| 179 |
+
let errorMessage = response.statusText;
|
| 180 |
+
try {
|
| 181 |
+
const errData = await response.json();
|
| 182 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 183 |
+
} catch (e) {
|
| 184 |
+
const errText = await response.text().catch(() => "");
|
| 185 |
+
if (errText) errorMessage = errText;
|
| 186 |
+
}
|
| 187 |
+
throw new Error(`OpenRouter API Error (${response.status}): ${errorMessage}`);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const data = await response.json();
|
| 191 |
+
console.log('OpenRouter Generate Response:', data);
|
| 192 |
+
const message = data.choices?.[0]?.message;
|
| 193 |
+
let content = "";
|
| 194 |
+
|
| 195 |
+
if (message) {
|
| 196 |
+
if (typeof message.content === 'string') {
|
| 197 |
+
content = message.content.trim();
|
| 198 |
+
} else if (Array.isArray(message.content)) {
|
| 199 |
+
// Handle cases where content might be returned as an array of parts
|
| 200 |
+
content = message.content
|
| 201 |
+
.filter((part: any) => part.type === 'text')
|
| 202 |
+
.map((part: any) => part.text)
|
| 203 |
+
.join('\n')
|
| 204 |
+
.trim();
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const refusal = message?.refusal;
|
| 209 |
+
const reasoning = message?.reasoning;
|
| 210 |
+
const finishReason = data.choices?.[0]?.finish_reason;
|
| 211 |
+
|
| 212 |
+
if (!content && refusal) {
|
| 213 |
+
throw new Error(`OpenRouter Refusal: ${refusal}`);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if (!content && finishReason === 'length') {
|
| 217 |
+
if (reasoning) {
|
| 218 |
+
// If we only have reasoning and it hit the length limit, the model likely
|
| 219 |
+
// spent all tokens "thinking" and never got to the output.
|
| 220 |
+
throw new Error("OpenRouter model hit token limit during reasoning. Try increasing max tokens or using a non-reasoning model.");
|
| 221 |
+
}
|
| 222 |
+
throw new Error("OpenRouter response was cut off (hit token limit).");
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
if (!content && finishReason === 'content_filter') {
|
| 226 |
+
throw new Error("OpenRouter response was blocked by content filter.");
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Some models might put the result in reasoning if content is null,
|
| 230 |
+
// though rare for standard chat completions.
|
| 231 |
+
return content || (reasoning ? `[Reasoning Only]: ${reasoning}` : "");
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
export const refineCaptionOpenRouter = async (
|
| 235 |
+
apiKey: string,
|
| 236 |
+
model: string,
|
| 237 |
+
file: File,
|
| 238 |
+
currentCaption: string,
|
| 239 |
+
refinementInstructions: string,
|
| 240 |
+
videoFrameCount: number = 4,
|
| 241 |
+
maxTokens: number = 4096,
|
| 242 |
+
temperature: number = 0.7,
|
| 243 |
+
useFullVideo: boolean = false,
|
| 244 |
+
signal?: AbortSignal
|
| 245 |
+
): Promise<string> => {
|
| 246 |
+
if (!apiKey) throw new Error("OpenRouter API Key is required.");
|
| 247 |
+
const endpoint = 'https://openrouter.ai/api/v1/chat/completions';
|
| 248 |
+
const prompt = `Refine the following caption based on the visual information and the instructions. Output ONLY the refined text.
|
| 249 |
+
CURRENT CAPTION: "${currentCaption}"
|
| 250 |
+
INSTRUCTIONS: "${refinementInstructions}"`;
|
| 251 |
+
|
| 252 |
+
let modelId = model.includes('openrouter.ai/') ? model.split('openrouter.ai/').pop() || '' : model;
|
| 253 |
+
if (modelId.startsWith('models/')) modelId = modelId.replace('models/', '');
|
| 254 |
+
modelId = modelId.split('?')[0].replace(/\/+$/, '');
|
| 255 |
+
|
| 256 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 257 |
+
if (file.type.startsWith('video/')) {
|
| 258 |
+
if (useFullVideo) {
|
| 259 |
+
const base64Video = await fileToBase64(file);
|
| 260 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 261 |
+
} else {
|
| 262 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount, signal);
|
| 263 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 264 |
+
}
|
| 265 |
+
} else {
|
| 266 |
+
const base64Image = await fileToBase64(file);
|
| 267 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
const payload = {
|
| 271 |
+
model: modelId || 'openai/gpt-4o-mini',
|
| 272 |
+
messages: [{ role: "user", content: contentParts }],
|
| 273 |
+
max_tokens: maxTokens,
|
| 274 |
+
temperature: temperature
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const response = await fetch(endpoint, {
|
| 278 |
+
method: "POST",
|
| 279 |
+
headers: {
|
| 280 |
+
"Content-Type": "application/json",
|
| 281 |
+
"Authorization": `Bearer ${apiKey}`,
|
| 282 |
+
"HTTP-Referer": window.location.origin,
|
| 283 |
+
"X-Title": "LoRA Caption Assistant"
|
| 284 |
+
},
|
| 285 |
+
body: JSON.stringify(payload),
|
| 286 |
+
signal
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
if (!response.ok) {
|
| 290 |
+
let errorMessage = response.statusText;
|
| 291 |
+
try {
|
| 292 |
+
const errData = await response.json();
|
| 293 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 294 |
+
} catch (e) {
|
| 295 |
+
const errText = await response.text().catch(() => "");
|
| 296 |
+
if (errText) errorMessage = errText;
|
| 297 |
+
}
|
| 298 |
+
throw new Error(`OpenRouter API Error (${response.status}): ${errorMessage}`);
|
| 299 |
+
}
|
| 300 |
+
const data = await response.json();
|
| 301 |
+
console.log('OpenRouter Refine Response:', data);
|
| 302 |
+
const content = data.choices?.[0]?.message?.content?.trim();
|
| 303 |
+
const refusal = data.choices?.[0]?.message?.refusal;
|
| 304 |
+
if (!content && refusal) throw new Error(`OpenRouter Refusal: ${refusal}`);
|
| 305 |
+
return content || "";
|
| 306 |
+
};
|
| 307 |
+
|
| 308 |
+
export const checkQualityOpenRouter = async (
|
| 309 |
+
apiKey: string,
|
| 310 |
+
model: string,
|
| 311 |
+
file: File,
|
| 312 |
+
caption: string,
|
| 313 |
+
videoFrameCount: number = 4,
|
| 314 |
+
temperature: number = 0.7,
|
| 315 |
+
useFullVideo: boolean = false,
|
| 316 |
+
signal?: AbortSignal
|
| 317 |
+
): Promise<number> => {
|
| 318 |
+
if (!apiKey) throw new Error("OpenRouter API Key is required.");
|
| 319 |
+
const endpoint = 'https://openrouter.ai/api/v1/chat/completions';
|
| 320 |
+
const prompt = `Evaluate the caption quality. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 321 |
+
|
| 322 |
+
let modelId = model.includes('openrouter.ai/') ? model.split('openrouter.ai/').pop() || '' : model;
|
| 323 |
+
if (modelId.startsWith('models/')) modelId = modelId.replace('models/', '');
|
| 324 |
+
modelId = modelId.split('?')[0].replace(/\/+$/, '');
|
| 325 |
+
|
| 326 |
+
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 327 |
+
if (file.type.startsWith('video/')) {
|
| 328 |
+
if (useFullVideo) {
|
| 329 |
+
const base64Video = await fileToBase64(file);
|
| 330 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Video } });
|
| 331 |
+
} else {
|
| 332 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount, signal);
|
| 333 |
+
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 334 |
+
}
|
| 335 |
+
} else {
|
| 336 |
+
const base64Image = await fileToBase64(file);
|
| 337 |
+
contentParts.push({ type: "image_url", image_url: { url: base64Image } });
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
const payload = {
|
| 341 |
+
model: modelId || 'openai/gpt-4o-mini',
|
| 342 |
+
messages: [{ role: "user", content: contentParts }],
|
| 343 |
+
max_tokens: 10,
|
| 344 |
+
temperature: temperature
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
const response = await fetch(endpoint, {
|
| 348 |
+
method: "POST",
|
| 349 |
+
headers: {
|
| 350 |
+
"Content-Type": "application/json",
|
| 351 |
+
"Authorization": `Bearer ${apiKey}`,
|
| 352 |
+
"HTTP-Referer": window.location.origin,
|
| 353 |
+
"X-Title": "LoRA Caption Assistant"
|
| 354 |
+
},
|
| 355 |
+
body: JSON.stringify(payload),
|
| 356 |
+
signal
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
if (!response.ok) {
|
| 360 |
+
let errorMessage = response.statusText;
|
| 361 |
+
try {
|
| 362 |
+
const errData = await response.json();
|
| 363 |
+
errorMessage = errData.error?.message || errData.message || JSON.stringify(errData) || errorMessage;
|
| 364 |
+
} catch (e) {
|
| 365 |
+
const errText = await response.text().catch(() => "");
|
| 366 |
+
if (errText) errorMessage = errText;
|
| 367 |
+
}
|
| 368 |
+
throw new Error(`OpenRouter API Error (${response.status}): ${errorMessage}`);
|
| 369 |
+
}
|
| 370 |
+
const data = await response.json();
|
| 371 |
+
console.log('OpenRouter Quality Response:', data);
|
| 372 |
+
const text = data.choices?.[0]?.message?.content?.trim();
|
| 373 |
+
const refusal = data.choices?.[0]?.message?.refusal;
|
| 374 |
+
if (!text && refusal) throw new Error(`OpenRouter Refusal: ${refusal}`);
|
| 375 |
+
return parseInt(text?.match(/\d+/)?.[0] || '0', 10);
|
| 376 |
+
};
|
services/qwenService.ts
CHANGED
|
@@ -158,7 +158,7 @@ CURRENT CAPTION: "${currentCaption}"
|
|
| 158 |
INSTRUCTIONS: "${refinementInstructions}"`;
|
| 159 |
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 160 |
if (file.type.startsWith('video/')) {
|
| 161 |
-
const frames = await extractFramesFromVideo(file,
|
| 162 |
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 163 |
} else {
|
| 164 |
const base64Image = await fileToBase64(file);
|
|
@@ -191,7 +191,7 @@ export const checkQualityQwen = async (
|
|
| 191 |
const prompt = `Evaluate the caption quality. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 192 |
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 193 |
if (file.type.startsWith('video/')) {
|
| 194 |
-
const frames = await extractFramesFromVideo(file,
|
| 195 |
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 196 |
} else {
|
| 197 |
const base64Image = await fileToBase64(file);
|
|
|
|
| 158 |
INSTRUCTIONS: "${refinementInstructions}"`;
|
| 159 |
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 160 |
if (file.type.startsWith('video/')) {
|
| 161 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount);
|
| 162 |
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 163 |
} else {
|
| 164 |
const base64Image = await fileToBase64(file);
|
|
|
|
| 191 |
const prompt = `Evaluate the caption quality. Respond with ONLY an integer from 1 to 5.\nCaption: "${caption}"`;
|
| 192 |
let contentParts: any[] = [{ type: "text", text: prompt }];
|
| 193 |
if (file.type.startsWith('video/')) {
|
| 194 |
+
const frames = await extractFramesFromVideo(file, videoFrameCount);
|
| 195 |
frames.forEach(frame => contentParts.push({ type: "image_url", image_url: { url: frame } }));
|
| 196 |
} else {
|
| 197 |
const base64Image = await fileToBase64(file);
|