comfyuiman commited on
Commit
5191cb5
·
verified ·
1 Parent(s): a0ddff4

Upload 21 files

Browse files
.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: { API_KEY?: string; [key: string]: string | undefined; }
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-2.5-pro-preview-09-2025', name: 'Gemini 2.5 Pro (Multimodal)' },
31
- { id: 'gemini-2.5-flash-native-audio-preview-09-2025', name: 'Gemini 2.5 Flash (Multimedia Speed)' }
 
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.API_KEY || '');
49
  const [geminiModel, setGeminiModel] = useState<string>(GEMINI_MODELS[0].id);
50
- const [hasSelectedKey, setHasSelectedKey] = useState<boolean>(false);
 
 
 
 
 
 
 
 
 
 
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, setQwenVideoFrameCount] = useState<number>(8);
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>('/mnt/Goon/captioner');
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 mediaUploads = files.filter(file => file.type.startsWith('image/') || file.type.startsWith('video/'));
196
- const newMediaFiles = await Promise.all(mediaUploads.map(async (file) => ({
197
- id: `${file.name}-${Math.random()}`,
198
- file,
199
- previewUrl: URL.createObjectURL(file),
200
- caption: '',
201
- status: GenerationStatus.IDLE,
202
- isSelected: false,
203
- customInstructions: '',
204
- comfyStatus: 'idle'
205
- } as MediaFile)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const score = apiProvider === 'gemini'
217
- ? await checkCaptionQuality(fileToProcess.file, fileToProcess.caption, abortControllerRef.current.signal, geminiApiKey, geminiModel)
218
- : await checkQualityQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, qwenVideoFrameCount, abortControllerRef.current.signal);
 
 
 
 
 
 
 
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
- const caption = apiProvider === 'gemini'
240
- ? await generateCaption(fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, abortControllerRef.current.signal, geminiApiKey, geminiModel)
241
- : await generateCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, qwenVideoFrameCount, abortControllerRef.current.signal);
 
 
 
 
 
 
 
 
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
- const caption = apiProvider === 'gemini'
263
- ? await refineCaption(fileToProcess.file, fileToProcess.caption, combinedInstructions, abortControllerRef.current.signal, geminiApiKey, geminiModel)
264
- : await refineCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, combinedInstructions, qwenVideoFrameCount, abortControllerRef.current.signal);
 
 
 
 
 
 
 
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 (abortControllerRef.current.signal.aborted) break;
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\npip install vllm bitsandbytes\necho Setup Complete.`
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-xs 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>
497
- <button onClick={() => setApiProvider('qwen')} className={`flex-1 py-3 text-xs 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 (GPU)</button>
 
 
498
  </div>
499
  </div>
500
 
501
- {apiProvider === 'gemini' ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <select
508
- value={geminiModel}
509
- onChange={(e) => setGeminiModel(e.target.value)}
510
- 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"
511
- >
512
- {GEMINI_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
513
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-2.5 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" />
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 Setup Script</button>
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={(id, sel) => updateFile(id, { isSelected: sel })}
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
- onChange={(e) => onSelectionChange(item.id, e.target.checked)}
 
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.API_KEY;
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.API_KEY;
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.API_KEY;
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, 4);
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, 4);
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);