@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.
Files changed (33) hide show
  1. package/dist/backend/movie-backend/agent/prompt/script.md +213 -0
  2. package/dist/backend/movie-backend/core.js +33 -0
  3. package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
  4. package/dist/backend/movie-backend/generation-queue.js +82 -6
  5. package/dist/backend/movie-backend/render-media.d.ts +89 -1
  6. package/dist/backend/movie-backend/render-media.js +772 -476
  7. package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
  8. package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
  9. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +67 -51
  10. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
  11. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
  12. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
  13. package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
  14. package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
  15. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +131 -126
  16. package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
  17. package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
  18. package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
  19. package/frontend/src/movie-app/stores/generationStore.ts +250 -345
  20. package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
  21. package/frontend/src/movie-app/stores/projectStore.ts +7 -3
  22. package/frontend/src/movie-app/stores/queueStore.ts +48 -1
  23. package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
  24. package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
  25. package/package.json +1 -1
  26. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
  27. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
  28. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
  29. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
  30. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
  31. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
  32. package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
  33. 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">