@effectnode/media 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/movie-backend/agent/prompt/script.md +213 -0
- package/dist/backend/movie-backend/core.js +33 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +82 -6
- package/dist/backend/movie-backend/render-media.d.ts +89 -1
- package/dist/backend/movie-backend/render-media.js +772 -476
- package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
- package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +67 -51
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
- package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +131 -126
- package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
- package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
- package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
- package/frontend/src/movie-app/stores/generationStore.ts +250 -345
- package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
- package/frontend/src/movie-app/stores/projectStore.ts +7 -3
- package/frontend/src/movie-app/stores/queueStore.ts +48 -1
- package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
- package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
- package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
- package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
- package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
- package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
- package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
- package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useQueueStore } from "../../stores/queueStore";
|
|
3
|
+
import { useProjectStore } from "../../stores/projectStore";
|
|
4
|
+
import { useAudioToVideoStore } from "../../stores/audioToVideoStore";
|
|
5
|
+
import TaskQueuePanel from "./TaskQueuePanel";
|
|
6
|
+
import TerminalLogPanel from "./TerminalLogPanel";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
projectId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function AudioToVideoTab({ projectId }: Props) {
|
|
13
|
+
const queue = useQueueStore();
|
|
14
|
+
const { openFolder } = useProjectStore();
|
|
15
|
+
const a2v = useAudioToVideoStore();
|
|
16
|
+
|
|
17
|
+
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
const audioInputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
a2v.fetchImages(projectId);
|
|
22
|
+
a2v.fetchAudios(projectId);
|
|
23
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
24
|
+
}, [projectId]);
|
|
25
|
+
|
|
26
|
+
// Stream the generation queue so audio-to-video tasks surface here.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
queue.startStreaming(projectId);
|
|
29
|
+
return () => queue.stopStreaming();
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [projectId]);
|
|
32
|
+
|
|
33
|
+
// Reconcile audio-to-video state with the latest queue task state.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
for (const task of queue.tasks) a2v.applyQueueTask(task);
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, [queue.tasks, projectId]);
|
|
38
|
+
|
|
39
|
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const file = e.target.files?.[0];
|
|
41
|
+
e.target.value = "";
|
|
42
|
+
if (!file) return;
|
|
43
|
+
const reader = new FileReader();
|
|
44
|
+
reader.onload = () => {
|
|
45
|
+
a2v.uploadImage(projectId, reader.result as string, file.name);
|
|
46
|
+
};
|
|
47
|
+
reader.readAsDataURL(file);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleAudioUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
const file = e.target.files?.[0];
|
|
52
|
+
e.target.value = "";
|
|
53
|
+
if (!file) return;
|
|
54
|
+
const reader = new FileReader();
|
|
55
|
+
reader.onload = () => {
|
|
56
|
+
a2v.uploadAudio(projectId, reader.result as string, file.name);
|
|
57
|
+
};
|
|
58
|
+
reader.readAsDataURL(file);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const busy =
|
|
62
|
+
a2v.generating || a2v.uploadingImage || a2v.uploadingAudio;
|
|
63
|
+
|
|
64
|
+
// ========== SVG Icons ==========
|
|
65
|
+
|
|
66
|
+
const VideoIcon = (
|
|
67
|
+
<svg
|
|
68
|
+
width="18"
|
|
69
|
+
height="18"
|
|
70
|
+
viewBox="0 0 24 24"
|
|
71
|
+
fill="none"
|
|
72
|
+
stroke="currentColor"
|
|
73
|
+
strokeWidth="2"
|
|
74
|
+
>
|
|
75
|
+
<polygon points="23 7 16 12 23 17 23 7" />
|
|
76
|
+
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const UploadIcon = (
|
|
81
|
+
<svg
|
|
82
|
+
width="14"
|
|
83
|
+
height="14"
|
|
84
|
+
viewBox="0 0 24 24"
|
|
85
|
+
fill="none"
|
|
86
|
+
stroke="currentColor"
|
|
87
|
+
strokeWidth="2"
|
|
88
|
+
strokeLinecap="round"
|
|
89
|
+
strokeLinejoin="round"
|
|
90
|
+
>
|
|
91
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
92
|
+
<polyline points="17 8 12 3 7 8" />
|
|
93
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
94
|
+
</svg>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const SparkleIcon = (
|
|
98
|
+
<svg
|
|
99
|
+
width="16"
|
|
100
|
+
height="16"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
fill="none"
|
|
103
|
+
stroke="currentColor"
|
|
104
|
+
strokeWidth="2"
|
|
105
|
+
>
|
|
106
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
107
|
+
</svg>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const SpinnerIcon = (
|
|
111
|
+
<svg
|
|
112
|
+
className="animate-spin text-tiffany-600"
|
|
113
|
+
width="16"
|
|
114
|
+
height="16"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2"
|
|
119
|
+
>
|
|
120
|
+
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
121
|
+
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
122
|
+
</svg>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const FolderIcon = (
|
|
126
|
+
<svg
|
|
127
|
+
width="16"
|
|
128
|
+
height="16"
|
|
129
|
+
viewBox="0 0 24 24"
|
|
130
|
+
fill="none"
|
|
131
|
+
stroke="currentColor"
|
|
132
|
+
strokeWidth="2"
|
|
133
|
+
strokeLinecap="round"
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
>
|
|
136
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
137
|
+
</svg>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="flex flex-col gap-7">
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<span className="text-tiffany-600">{VideoIcon}</span>
|
|
144
|
+
<h2 className="text-base font-semibold text-ink-900">Audio to Video</h2>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<TaskQueuePanel projectId={projectId} />
|
|
148
|
+
<TerminalLogPanel />
|
|
149
|
+
|
|
150
|
+
{/* ===== Image ===== */}
|
|
151
|
+
<div>
|
|
152
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
153
|
+
Image
|
|
154
|
+
</label>
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => imageInputRef.current?.click()}
|
|
158
|
+
disabled={a2v.uploadingImage || a2v.generating}
|
|
159
|
+
className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 disabled:opacity-50 transition-colors"
|
|
160
|
+
>
|
|
161
|
+
{UploadIcon}
|
|
162
|
+
Upload Image
|
|
163
|
+
</button>
|
|
164
|
+
<input
|
|
165
|
+
ref={imageInputRef}
|
|
166
|
+
type="file"
|
|
167
|
+
accept="image/*"
|
|
168
|
+
onChange={handleImageUpload}
|
|
169
|
+
className="hidden"
|
|
170
|
+
/>
|
|
171
|
+
{a2v.image && (
|
|
172
|
+
<span className="truncate text-xs text-ink-600">
|
|
173
|
+
{a2v.image.filename}
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{a2v.imagesLoading ? (
|
|
179
|
+
<p className="text-xs text-ink-500 italic py-3 text-center">
|
|
180
|
+
Loading images…
|
|
181
|
+
</p>
|
|
182
|
+
) : a2v.images.length === 0 ? null : (
|
|
183
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-2 p-1 mt-2">
|
|
184
|
+
{a2v.images.map((img) => {
|
|
185
|
+
const isSelected = a2v.image?.filename === img.filename;
|
|
186
|
+
return (
|
|
187
|
+
<button
|
|
188
|
+
key={img.filename}
|
|
189
|
+
onClick={() => a2v.setImage(img)}
|
|
190
|
+
disabled={a2v.generating}
|
|
191
|
+
className={`relative rounded-xl border-2 overflow-hidden transition-all ${
|
|
192
|
+
isSelected
|
|
193
|
+
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
194
|
+
: "border-ink-200 hover:border-ink-300"
|
|
195
|
+
} disabled:opacity-50`}
|
|
196
|
+
>
|
|
197
|
+
<img
|
|
198
|
+
src={img.url}
|
|
199
|
+
alt={img.filename}
|
|
200
|
+
className="aspect-square object-cover object-center w-full"
|
|
201
|
+
/>
|
|
202
|
+
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 text-[10px] text-ink-700 truncate text-center">
|
|
203
|
+
{img.filename}
|
|
204
|
+
</span>
|
|
205
|
+
</button>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* ===== Audio ===== */}
|
|
213
|
+
<div>
|
|
214
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
215
|
+
Audio (MP3 / WAV)
|
|
216
|
+
</label>
|
|
217
|
+
<div className="flex items-center gap-2">
|
|
218
|
+
<button
|
|
219
|
+
onClick={() => audioInputRef.current?.click()}
|
|
220
|
+
disabled={a2v.uploadingAudio || a2v.generating}
|
|
221
|
+
className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 disabled:opacity-50 transition-colors"
|
|
222
|
+
>
|
|
223
|
+
{UploadIcon}
|
|
224
|
+
Upload Audio
|
|
225
|
+
</button>
|
|
226
|
+
<input
|
|
227
|
+
ref={audioInputRef}
|
|
228
|
+
type="file"
|
|
229
|
+
accept="audio/*"
|
|
230
|
+
onChange={handleAudioUpload}
|
|
231
|
+
className="hidden"
|
|
232
|
+
/>
|
|
233
|
+
{a2v.audio && (
|
|
234
|
+
<span className="truncate text-xs text-ink-600">
|
|
235
|
+
{a2v.audio.filename}
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{a2v.audiosLoading ? (
|
|
241
|
+
<p className="text-xs text-ink-500 italic py-3 text-center">
|
|
242
|
+
Loading audios…
|
|
243
|
+
</p>
|
|
244
|
+
) : a2v.audios.length === 0 ? null : (
|
|
245
|
+
<ul className="mt-2 divide-y divide-ink-200 border border-ink-200 rounded-2xl overflow-hidden max-h-48 overflow-y-auto">
|
|
246
|
+
{a2v.audios.map((a) => {
|
|
247
|
+
const isSelected = a2v.audio?.filename === a.filename;
|
|
248
|
+
return (
|
|
249
|
+
<li key={a.filename}>
|
|
250
|
+
<button
|
|
251
|
+
onClick={() => a2v.setAudio(a)}
|
|
252
|
+
disabled={a2v.generating}
|
|
253
|
+
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-xs transition-colors disabled:opacity-50 ${
|
|
254
|
+
isSelected
|
|
255
|
+
? "bg-ink-100 text-ink-800"
|
|
256
|
+
: "text-ink-600 hover:bg-ink-100"
|
|
257
|
+
}`}
|
|
258
|
+
>
|
|
259
|
+
<span className="truncate">{a.filename}</span>
|
|
260
|
+
</button>
|
|
261
|
+
</li>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</ul>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* ===== Steps ===== */}
|
|
269
|
+
<div>
|
|
270
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
271
|
+
Stage-1 Steps
|
|
272
|
+
</label>
|
|
273
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
274
|
+
<button
|
|
275
|
+
onClick={() => a2v.setSteps(15)}
|
|
276
|
+
disabled={a2v.generating}
|
|
277
|
+
className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
|
|
278
|
+
a2v.steps === 15
|
|
279
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
280
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
281
|
+
} disabled:opacity-50`}
|
|
282
|
+
>
|
|
283
|
+
15 (default)
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
onClick={() => a2v.setSteps(30)}
|
|
287
|
+
disabled={a2v.generating}
|
|
288
|
+
className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
|
|
289
|
+
a2v.steps === 30
|
|
290
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
291
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
292
|
+
} disabled:opacity-50`}
|
|
293
|
+
>
|
|
294
|
+
30 (high quality)
|
|
295
|
+
</button>
|
|
296
|
+
<input
|
|
297
|
+
type="number"
|
|
298
|
+
min={1}
|
|
299
|
+
step={1}
|
|
300
|
+
value={a2v.steps}
|
|
301
|
+
onChange={(e) => a2v.setSteps(Number(e.target.value))}
|
|
302
|
+
disabled={a2v.generating}
|
|
303
|
+
className="w-24 px-3 py-1.5 text-xs bg-ink-50 border border-ink-200 rounded-xl text-ink-800 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/25 disabled:opacity-50"
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* ===== Duration ===== */}
|
|
309
|
+
<div>
|
|
310
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
311
|
+
Duration (seconds)
|
|
312
|
+
</label>
|
|
313
|
+
<input
|
|
314
|
+
type="number"
|
|
315
|
+
min={1}
|
|
316
|
+
step={1}
|
|
317
|
+
value={a2v.duration}
|
|
318
|
+
onChange={(e) => a2v.setDuration(Number(e.target.value))}
|
|
319
|
+
disabled={a2v.generating}
|
|
320
|
+
className="w-32 px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* ===== Frames ===== */}
|
|
325
|
+
<div>
|
|
326
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
327
|
+
Frames
|
|
328
|
+
</label>
|
|
329
|
+
<input
|
|
330
|
+
type="number"
|
|
331
|
+
min={1}
|
|
332
|
+
step={1}
|
|
333
|
+
value={a2v.frames}
|
|
334
|
+
onChange={(e) => a2v.setFrames(Number(e.target.value))}
|
|
335
|
+
disabled={a2v.generating}
|
|
336
|
+
className="w-32 px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
337
|
+
/>
|
|
338
|
+
<p className="text-xs text-ink-600/50 mt-1.5">
|
|
339
|
+
Auto-updated from duration: 1 second = 24 frames + 1 (24n+1)
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* ===== Prompt ===== */}
|
|
344
|
+
<div>
|
|
345
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
346
|
+
Scene Prompt
|
|
347
|
+
</label>
|
|
348
|
+
<textarea
|
|
349
|
+
value={a2v.prompt}
|
|
350
|
+
onChange={(e) => a2v.setPrompt(e.target.value)}
|
|
351
|
+
placeholder="Describe the scene, e.g. scene at restaurant"
|
|
352
|
+
rows={3}
|
|
353
|
+
disabled={a2v.generating}
|
|
354
|
+
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
|
|
355
|
+
/>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{/* ===== Generate ===== */}
|
|
359
|
+
{a2v.generating ? (
|
|
360
|
+
<div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
|
|
361
|
+
{SpinnerIcon}
|
|
362
|
+
<span className="text-sm font-medium text-ink-700">
|
|
363
|
+
Generating video…
|
|
364
|
+
</span>
|
|
365
|
+
</div>
|
|
366
|
+
) : (
|
|
367
|
+
<button
|
|
368
|
+
onClick={() => a2v.generate(projectId)}
|
|
369
|
+
disabled={busy || !a2v.image || !a2v.audio}
|
|
370
|
+
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm hover:shadow-md disabled:shadow-none"
|
|
371
|
+
>
|
|
372
|
+
{SparkleIcon}
|
|
373
|
+
Generate Video
|
|
374
|
+
</button>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* ===== Open output folder ===== */}
|
|
378
|
+
<button
|
|
379
|
+
onClick={() => openFolder(projectId, "output")}
|
|
380
|
+
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-ink-50 hover:bg-ink-200 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors"
|
|
381
|
+
>
|
|
382
|
+
{FolderIcon}
|
|
383
|
+
Open Output Folder
|
|
384
|
+
</button>
|
|
385
|
+
|
|
386
|
+
{/* ===== Error ===== */}
|
|
387
|
+
{a2v.error && (
|
|
388
|
+
<div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
|
|
389
|
+
{a2v.error}
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
{/* ===== Result ===== */}
|
|
394
|
+
{a2v.result && (
|
|
395
|
+
<div>
|
|
396
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
397
|
+
Generated Video
|
|
398
|
+
</label>
|
|
399
|
+
<div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black">
|
|
400
|
+
<video src={a2v.result} controls className="w-full h-auto" />
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useGenerationStore } from "../../stores/generationStore";
|
|
3
3
|
import { useProjectStore } from "../../stores/projectStore";
|
|
4
|
+
import { useQueueStore } from "../../stores/queueStore";
|
|
5
|
+
import TaskQueuePanel from "./TaskQueuePanel";
|
|
6
|
+
import TerminalLogPanel from "./TerminalLogPanel";
|
|
4
7
|
|
|
5
8
|
interface Props {
|
|
6
9
|
projectId: string;
|
|
@@ -9,6 +12,7 @@ interface Props {
|
|
|
9
12
|
export default function FastImageEditTab({ projectId }: Props) {
|
|
10
13
|
const store = useGenerationStore();
|
|
11
14
|
const { openFolder } = useProjectStore();
|
|
15
|
+
const queue = useQueueStore();
|
|
12
16
|
const referenceFileRef = useRef<HTMLInputElement>(null);
|
|
13
17
|
const [preview, setPreview] = useState<{
|
|
14
18
|
url: string;
|
|
@@ -18,10 +22,22 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
18
22
|
useEffect(() => {
|
|
19
23
|
store.checkFastImageEditStatus();
|
|
20
24
|
store.fetchProjectImages(projectId);
|
|
21
|
-
store.fetchCharacterSheets(projectId);
|
|
22
25
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
23
26
|
}, [projectId]);
|
|
24
27
|
|
|
28
|
+
// Stream the generation queue so fast-image-edit tasks surface here.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
queue.startStreaming(projectId);
|
|
31
|
+
return () => queue.stopStreaming();
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [projectId]);
|
|
34
|
+
|
|
35
|
+
// Reconcile fast-image-edit state with the latest queue task state.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
for (const task of queue.tasks) store.applyFastImageEditQueueTask(task);
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [queue.tasks, projectId]);
|
|
40
|
+
|
|
25
41
|
// Close the preview modal on Escape.
|
|
26
42
|
useEffect(() => {
|
|
27
43
|
if (!preview) return;
|
|
@@ -247,6 +263,9 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
247
263
|
</h2>
|
|
248
264
|
</div>
|
|
249
265
|
|
|
266
|
+
<TaskQueuePanel projectId={projectId} />
|
|
267
|
+
<TerminalLogPanel />
|
|
268
|
+
|
|
250
269
|
{/* ===== Setup: download model ===== */}
|
|
251
270
|
<div>
|
|
252
271
|
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
@@ -381,56 +400,6 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
381
400
|
)}
|
|
382
401
|
</div>
|
|
383
402
|
|
|
384
|
-
{/* ===== Character sheet picker ===== */}
|
|
385
|
-
{store.characterSheets.length > 0 && (
|
|
386
|
-
<div>
|
|
387
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
388
|
-
Character Sheet
|
|
389
|
-
</label>
|
|
390
|
-
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2 p-1">
|
|
391
|
-
{store.characterSheets.map((sheet) => {
|
|
392
|
-
const isSelected = store.fastImageEdit.referenceImages.some(
|
|
393
|
-
(r) => r.filename === sheet.filename,
|
|
394
|
-
);
|
|
395
|
-
const fullUrl = sheet.url.startsWith("http")
|
|
396
|
-
? sheet.url
|
|
397
|
-
: `http://localhost:${(window as any).PORT}${sheet.url}`;
|
|
398
|
-
return (
|
|
399
|
-
<div key={sheet.filename} className="relative group">
|
|
400
|
-
<button
|
|
401
|
-
onClick={() => store.toggleFastImageEditImage(sheet)}
|
|
402
|
-
disabled={store.fastImageEdit.generating}
|
|
403
|
-
className={`w-full relative rounded-xl border-2 overflow-hidden transition-all ${
|
|
404
|
-
isSelected
|
|
405
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
406
|
-
: "border-ink-200 hover:border-ink-300"
|
|
407
|
-
} disabled:opacity-50`}
|
|
408
|
-
>
|
|
409
|
-
<img
|
|
410
|
-
src={fullUrl}
|
|
411
|
-
alt={sheet.filename}
|
|
412
|
-
className="aspect-square object-cover object-center w-full"
|
|
413
|
-
/>
|
|
414
|
-
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 text-[10px] text-ink-700 truncate text-center">
|
|
415
|
-
{sheet.filename}
|
|
416
|
-
</span>
|
|
417
|
-
</button>
|
|
418
|
-
<button
|
|
419
|
-
onClick={() =>
|
|
420
|
-
setPreview({ url: fullUrl, filename: sheet.filename })
|
|
421
|
-
}
|
|
422
|
-
className="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded-full bg-black/50 text-white hover:bg-tiffany-600 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
|
423
|
-
title="Preview"
|
|
424
|
-
>
|
|
425
|
-
{MaximizeIcon}
|
|
426
|
-
</button>
|
|
427
|
-
</div>
|
|
428
|
-
);
|
|
429
|
-
})}
|
|
430
|
-
</div>
|
|
431
|
-
</div>
|
|
432
|
-
)}
|
|
433
|
-
|
|
434
403
|
{/* ===== Prompt ===== */}
|
|
435
404
|
<div>
|
|
436
405
|
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
@@ -446,6 +415,53 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
446
415
|
/>
|
|
447
416
|
</div>
|
|
448
417
|
|
|
418
|
+
{/* ===== Steps ===== */}
|
|
419
|
+
<div>
|
|
420
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
421
|
+
Steps
|
|
422
|
+
</label>
|
|
423
|
+
<input
|
|
424
|
+
type="number"
|
|
425
|
+
min={1}
|
|
426
|
+
step={1}
|
|
427
|
+
value={store.fastImageEdit.steps}
|
|
428
|
+
onChange={(e) => store.setFastImageEditSteps(Number(e.target.value))}
|
|
429
|
+
disabled={store.fastImageEdit.generating}
|
|
430
|
+
className="w-32 px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* ===== Upscale Result (optional) ===== */}
|
|
435
|
+
<div>
|
|
436
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
437
|
+
Upscale Result (optional)
|
|
438
|
+
</label>
|
|
439
|
+
<div className="flex flex-wrap gap-2">
|
|
440
|
+
{(
|
|
441
|
+
[
|
|
442
|
+
{ value: "none", label: "None" },
|
|
443
|
+
{ value: "1x", label: "1x" },
|
|
444
|
+
{ value: "1500", label: "1500px" },
|
|
445
|
+
{ value: "2000", label: "2000px" },
|
|
446
|
+
{ value: "2500", label: "2500px" },
|
|
447
|
+
] as const
|
|
448
|
+
).map((o) => (
|
|
449
|
+
<button
|
|
450
|
+
key={o.value}
|
|
451
|
+
onClick={() => store.setFastImageEditUpscale(o.value)}
|
|
452
|
+
disabled={store.fastImageEdit.generating}
|
|
453
|
+
className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
|
|
454
|
+
store.fastImageEdit.upscale === o.value
|
|
455
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
456
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
457
|
+
} disabled:opacity-50`}
|
|
458
|
+
>
|
|
459
|
+
{o.label}
|
|
460
|
+
</button>
|
|
461
|
+
))}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
449
465
|
{/* ===== Generate ===== */}
|
|
450
466
|
{store.fastImageEdit.generating ? (
|
|
451
467
|
<div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
|