@effectnode/media 0.1.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 (133) hide show
  1. package/bin/effectnode-media +2 -0
  2. package/dist/backend/index.d.ts +8 -0
  3. package/dist/backend/index.js +13 -0
  4. package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
  5. package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
  6. package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
  7. package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
  8. package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
  9. package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
  10. package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
  11. package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
  12. package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
  13. package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
  14. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
  15. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
  16. package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
  17. package/dist/backend/movie-backend/agent/tools/index.js +59 -0
  18. package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
  19. package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
  20. package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
  21. package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
  22. package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
  23. package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
  24. package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
  25. package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
  26. package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
  27. package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
  28. package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
  29. package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
  30. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
  31. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
  32. package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
  33. package/dist/backend/movie-backend/agent/tools/types.js +1 -0
  34. package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
  35. package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
  36. package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
  37. package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
  38. package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
  39. package/dist/backend/movie-backend/agent/workspace.js +124 -0
  40. package/dist/backend/movie-backend/core.d.ts +4 -0
  41. package/dist/backend/movie-backend/core.js +1167 -0
  42. package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
  43. package/dist/backend/movie-backend/generation-queue.js +766 -0
  44. package/dist/backend/movie-backend/process.d.ts +21 -0
  45. package/dist/backend/movie-backend/process.js +98 -0
  46. package/dist/backend/movie-backend/render-media.d.ts +48 -0
  47. package/dist/backend/movie-backend/render-media.js +3311 -0
  48. package/dist/backend/routes/health.d.ts +1 -0
  49. package/dist/backend/routes/health.js +9 -0
  50. package/dist/backend/routes/index.d.ts +1 -0
  51. package/dist/backend/routes/index.js +11 -0
  52. package/dist/backend/routes/media.d.ts +1 -0
  53. package/dist/backend/routes/media.js +13 -0
  54. package/dist/backend/routes/projects.d.ts +8 -0
  55. package/dist/backend/routes/projects.js +68 -0
  56. package/dist/backend/routes/voice.d.ts +1 -0
  57. package/dist/backend/routes/voice.js +39 -0
  58. package/dist/backend/routes.d.ts +1 -0
  59. package/dist/backend/routes.js +20 -0
  60. package/dist/backend/server.d.ts +1 -0
  61. package/dist/backend/server.js +23 -0
  62. package/dist/backend/workspace.d.ts +5 -0
  63. package/dist/backend/workspace.js +27 -0
  64. package/dist/backend/ws/index.d.ts +3 -0
  65. package/dist/backend/ws/index.js +27 -0
  66. package/dist/backend/ws/messages.d.ts +8 -0
  67. package/dist/backend/ws/messages.js +21 -0
  68. package/dist/backend/ws.d.ts +3 -0
  69. package/dist/backend/ws.js +29 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +80 -0
  72. package/frontend/index.html +22 -0
  73. package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
  74. package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
  75. package/frontend/src/index.css +46 -0
  76. package/frontend/src/main.tsx +13 -0
  77. package/frontend/src/movie-app/AppRouter.tsx +14 -0
  78. package/frontend/src/movie-app/MediaStudio.tsx +9 -0
  79. package/frontend/src/movie-app/SetupPage.tsx +293 -0
  80. package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
  81. package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
  82. package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
  83. package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
  84. package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
  85. package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
  86. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
  87. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
  88. package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
  89. package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
  90. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
  91. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
  92. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
  93. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
  94. package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
  95. package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
  96. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
  97. package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
  98. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
  99. package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
  100. package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
  101. package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
  102. package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
  103. package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
  104. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
  105. package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
  106. package/frontend/src/movie-app/index.css +46 -0
  107. package/frontend/src/movie-app/index.html +16 -0
  108. package/frontend/src/movie-app/lambobo.png +0 -0
  109. package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
  110. package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
  111. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
  112. package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
  113. package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
  114. package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
  115. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
  116. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
  117. package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
  118. package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
  119. package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
  120. package/frontend/src/movie-app/stores/characterStore.ts +109 -0
  121. package/frontend/src/movie-app/stores/chatStore.ts +459 -0
  122. package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
  123. package/frontend/src/movie-app/stores/logStore.ts +25 -0
  124. package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
  125. package/frontend/src/movie-app/stores/projectStore.ts +124 -0
  126. package/frontend/src/movie-app/stores/queueStore.ts +214 -0
  127. package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
  128. package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
  129. package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
  130. package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
  131. package/frontend/src/vite-env.d.ts +1 -0
  132. package/frontend/tsconfig.json +19 -0
  133. package/package.json +65 -0
@@ -0,0 +1,555 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useGenerationStore } from "../../stores/generationStore";
3
+ import { useProjectStore } from "../../stores/projectStore";
4
+
5
+ interface Props {
6
+ projectId: string;
7
+ }
8
+
9
+ export default function FastImageEditTab({ projectId }: Props) {
10
+ const store = useGenerationStore();
11
+ const { openFolder } = useProjectStore();
12
+ const referenceFileRef = useRef<HTMLInputElement>(null);
13
+ const [preview, setPreview] = useState<{
14
+ url: string;
15
+ filename: string;
16
+ } | null>(null);
17
+
18
+ useEffect(() => {
19
+ store.checkFastImageEditStatus();
20
+ store.fetchProjectImages(projectId);
21
+ store.fetchCharacterSheets(projectId);
22
+ // eslint-disable-next-line react-hooks/exhaustive-deps
23
+ }, [projectId]);
24
+
25
+ // Close the preview modal on Escape.
26
+ useEffect(() => {
27
+ if (!preview) return;
28
+ const onKey = (e: KeyboardEvent) => {
29
+ if (e.key === "Escape") setPreview(null);
30
+ };
31
+ window.addEventListener("keydown", onKey);
32
+ return () => window.removeEventListener("keydown", onKey);
33
+ }, [preview]);
34
+
35
+ const handleReferenceUpload = async (
36
+ e: React.ChangeEvent<HTMLInputElement>,
37
+ ) => {
38
+ const file = e.target.files?.[0];
39
+ if (!file) return;
40
+
41
+ const reader = new FileReader();
42
+ reader.onload = async () => {
43
+ const base64 = reader.result as string;
44
+ const uploadedPath = await store.uploadImage(
45
+ projectId,
46
+ base64,
47
+ file.name,
48
+ );
49
+ if (uploadedPath) {
50
+ await store.fetchProjectImages(projectId);
51
+ const { uploadedImageFilename, uploadedImageUrl } =
52
+ useGenerationStore.getState();
53
+ store.toggleFastImageEditImage({
54
+ filename: uploadedImageFilename || file.name,
55
+ url: uploadedImageUrl || "",
56
+ source: "upload",
57
+ });
58
+ }
59
+ };
60
+ reader.readAsDataURL(file);
61
+
62
+ e.target.value = "";
63
+ };
64
+
65
+ const busy =
66
+ store.fastImageEdit.generating || store.fastImageEdit.downloading;
67
+
68
+ const renderStatus = (
69
+ value: boolean | null,
70
+ okText: string,
71
+ missingText: string,
72
+ ) => {
73
+ if (value === null) {
74
+ return (
75
+ <span className="inline-flex items-center gap-1.5 text-ink-500">
76
+ {SpinnerIcon}
77
+ Checking...
78
+ </span>
79
+ );
80
+ }
81
+ if (value) {
82
+ return (
83
+ <span className="inline-flex items-center gap-1.5 text-emerald-600">
84
+ {CheckCircleIcon}
85
+ {okText}
86
+ </span>
87
+ );
88
+ }
89
+ return (
90
+ <span className="inline-flex items-center gap-1.5 text-amber-600">
91
+ {AlertCircleIcon}
92
+ {missingText}
93
+ </span>
94
+ );
95
+ };
96
+
97
+ // ========== SVG Icons ==========
98
+
99
+ const FastImageEditIcon = (
100
+ <svg
101
+ width="18"
102
+ height="18"
103
+ viewBox="0 0 24 24"
104
+ fill="none"
105
+ stroke="currentColor"
106
+ strokeWidth="2"
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ >
110
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
111
+ </svg>
112
+ );
113
+
114
+ const DownloadIcon = (
115
+ <svg
116
+ width="16"
117
+ height="16"
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ strokeWidth="2"
122
+ strokeLinecap="round"
123
+ strokeLinejoin="round"
124
+ >
125
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
126
+ <polyline points="7 10 12 15 17 10" />
127
+ <line x1="12" y1="15" x2="12" y2="3" />
128
+ </svg>
129
+ );
130
+
131
+ const SparkleIcon = (
132
+ <svg
133
+ width="16"
134
+ height="16"
135
+ viewBox="0 0 24 24"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ strokeWidth="2"
139
+ >
140
+ <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" />
141
+ </svg>
142
+ );
143
+
144
+ const SpinnerIcon = (
145
+ <svg
146
+ className="animate-spin text-tiffany-600"
147
+ width="16"
148
+ height="16"
149
+ viewBox="0 0 24 24"
150
+ fill="none"
151
+ stroke="currentColor"
152
+ strokeWidth="2"
153
+ >
154
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
155
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
156
+ </svg>
157
+ );
158
+
159
+ const CheckCircleIcon = (
160
+ <svg
161
+ width="14"
162
+ height="14"
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ strokeWidth="2"
167
+ strokeLinecap="round"
168
+ strokeLinejoin="round"
169
+ >
170
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
171
+ <polyline points="22 4 12 14.01 9 11.01" />
172
+ </svg>
173
+ );
174
+
175
+ const AlertCircleIcon = (
176
+ <svg
177
+ width="14"
178
+ height="14"
179
+ viewBox="0 0 24 24"
180
+ fill="none"
181
+ stroke="currentColor"
182
+ strokeWidth="2"
183
+ strokeLinecap="round"
184
+ strokeLinejoin="round"
185
+ >
186
+ <circle cx="12" cy="12" r="10" />
187
+ <line x1="12" y1="8" x2="12" y2="12" />
188
+ <line x1="12" y1="16" x2="12.01" y2="16" />
189
+ </svg>
190
+ );
191
+
192
+ const CloseIcon = (
193
+ <svg
194
+ width="18"
195
+ height="18"
196
+ viewBox="0 0 24 24"
197
+ fill="none"
198
+ stroke="currentColor"
199
+ strokeWidth="2"
200
+ strokeLinecap="round"
201
+ strokeLinejoin="round"
202
+ >
203
+ <line x1="18" y1="6" x2="6" y2="18" />
204
+ <line x1="6" y1="6" x2="18" y2="18" />
205
+ </svg>
206
+ );
207
+
208
+ const MaximizeIcon = (
209
+ <svg
210
+ width="12"
211
+ height="12"
212
+ viewBox="0 0 24 24"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ strokeWidth="2"
216
+ strokeLinecap="round"
217
+ strokeLinejoin="round"
218
+ >
219
+ <polyline points="15 3 21 3 21 9" />
220
+ <polyline points="9 21 3 21 3 15" />
221
+ <line x1="21" y1="3" x2="14" y2="10" />
222
+ <line x1="3" y1="21" x2="10" y2="14" />
223
+ </svg>
224
+ );
225
+
226
+ const FolderIcon = (
227
+ <svg
228
+ width="16"
229
+ height="16"
230
+ viewBox="0 0 24 24"
231
+ fill="none"
232
+ stroke="currentColor"
233
+ strokeWidth="2"
234
+ strokeLinecap="round"
235
+ strokeLinejoin="round"
236
+ >
237
+ <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" />
238
+ </svg>
239
+ );
240
+
241
+ return (
242
+ <div className="flex flex-col gap-7">
243
+ <div className="flex items-center gap-2">
244
+ <span className="text-tiffany-600">{FastImageEditIcon}</span>
245
+ <h2 className="text-base font-semibold text-ink-900">
246
+ Fast Image Edit
247
+ </h2>
248
+ </div>
249
+
250
+ {/* ===== Setup: download model ===== */}
251
+ <div>
252
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
253
+ Model Setup
254
+ </label>
255
+ <div className="flex flex-wrap gap-2">
256
+ <button
257
+ onClick={() => store.downloadFastImageEditModel()}
258
+ disabled={store.fastImageEdit.downloading}
259
+ className="flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
260
+ >
261
+ {store.fastImageEdit.downloading ? SpinnerIcon : DownloadIcon}
262
+ Download FLUX.2 Klein Model
263
+ </button>
264
+ </div>
265
+
266
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-2 text-xs font-medium">
267
+ {renderStatus(
268
+ store.fastImageEdit.modelDownloaded,
269
+ "FLUX.2 Klein model downloaded",
270
+ "FLUX.2 Klein model not downloaded",
271
+ )}
272
+ </div>
273
+
274
+ {store.fastImageEdit.downloadingLogs.length > 0 && (
275
+ <div className="mt-2 p-4 bg-ink-50 border border-ink-200 rounded-2xl max-h-32 overflow-y-auto">
276
+ <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
277
+ {store.fastImageEdit.downloadingLogs.join("")}
278
+ </pre>
279
+ </div>
280
+ )}
281
+ {store.fastImageEdit.downloadingError && (
282
+ <div className="mt-2 p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
283
+ {store.fastImageEdit.downloadingError}
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ {/* ===== Reference images picker ===== */}
289
+ <div>
290
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
291
+ Reference Images
292
+ </label>
293
+ <div className="flex items-center gap-2">
294
+ <input
295
+ ref={referenceFileRef}
296
+ type="file"
297
+ accept="image/*"
298
+ onChange={handleReferenceUpload}
299
+ disabled={store.uploading}
300
+ className="inline-block text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
301
+ />
302
+ {store.fastImageEdit.referenceImages.length > 0 && (
303
+ <button
304
+ onClick={() => store.clearFastImageEditImages()}
305
+ disabled={busy}
306
+ className="px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50 rounded-xl transition-colors disabled:opacity-50"
307
+ >
308
+ Clear ({store.fastImageEdit.referenceImages.length})
309
+ </button>
310
+ )}
311
+ </div>
312
+ {store.uploading && (
313
+ <p className="text-xs text-ink-600 mt-1">Uploading...</p>
314
+ )}
315
+ {store.uploadError && (
316
+ <p className="text-xs text-red-600 mt-1">{store.uploadError}</p>
317
+ )}
318
+ {store.projectImagesLoading ? (
319
+ <p className="text-xs text-ink-500 italic py-4 text-center">
320
+ Loading images...
321
+ </p>
322
+ ) : store.projectImages.length === 0 ? (
323
+ <p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
324
+ No images yet. Upload reference images above.
325
+ </p>
326
+ ) : (
327
+ <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2 p-1">
328
+ {store.projectImages.map((img) => {
329
+ const isSelected = store.fastImageEdit.referenceImages.some(
330
+ (r) => r.filename === img.filename,
331
+ );
332
+ const fullUrl = img.url.startsWith("http")
333
+ ? img.url
334
+ : `http://localhost:${(window as any).PORT}${img.url}`;
335
+ return (
336
+ <div
337
+ key={`${img.source}-${img.filename}`}
338
+ className="relative group"
339
+ >
340
+ <button
341
+ onClick={() => store.toggleFastImageEditImage(img)}
342
+ disabled={store.fastImageEdit.generating}
343
+ className={`w-full relative rounded-xl border-2 overflow-hidden transition-all ${
344
+ isSelected
345
+ ? "border-tiffany-500 ring-2 ring-tiffany-500/40"
346
+ : "border-ink-200 hover:border-ink-300"
347
+ } disabled:opacity-50`}
348
+ >
349
+ <img
350
+ src={fullUrl}
351
+ alt={img.filename}
352
+ className="aspect-square object-cover object-center w-full"
353
+ />
354
+ <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">
355
+ {img.filename}
356
+ </span>
357
+ </button>
358
+ <button
359
+ onClick={() =>
360
+ setPreview({ url: fullUrl, filename: img.filename })
361
+ }
362
+ 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"
363
+ title="Preview"
364
+ >
365
+ {MaximizeIcon}
366
+ </button>
367
+ </div>
368
+ );
369
+ })}
370
+ </div>
371
+ )}
372
+ {store.fastImageEdit.referenceImages.length > 0 && (
373
+ <p className="text-xs text-ink-600/60 mt-1.5">
374
+ Selected:{" "}
375
+ <span className="font-medium text-ink-700">
376
+ {store.fastImageEdit.referenceImages
377
+ .map((r) => r.filename)
378
+ .join(", ")}
379
+ </span>
380
+ </p>
381
+ )}
382
+ </div>
383
+
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
+ {/* ===== Prompt ===== */}
435
+ <div>
436
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
437
+ Image Prompt
438
+ </label>
439
+ <textarea
440
+ value={store.fastImageEdit.prompt}
441
+ onChange={(e) => store.setFastImageEditPrompt(e.target.value)}
442
+ placeholder="Describe the composite image, e.g. The lamb and The ninja standing next to each other. taking a photo in a photo shooting studio."
443
+ rows={3}
444
+ disabled={store.fastImageEdit.generating}
445
+ 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"
446
+ />
447
+ </div>
448
+
449
+ {/* ===== Generate ===== */}
450
+ {store.fastImageEdit.generating ? (
451
+ <div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
452
+ {SpinnerIcon}
453
+ <span className="text-sm font-medium text-ink-700">
454
+ Generating...
455
+ </span>
456
+ </div>
457
+ ) : (
458
+ <button
459
+ onClick={() => store.generateFastImageEdit(projectId)}
460
+ disabled={
461
+ busy ||
462
+ !store.fastImageEdit.prompt.trim() ||
463
+ store.fastImageEdit.referenceImages.length === 0
464
+ }
465
+ 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"
466
+ >
467
+ {SparkleIcon}
468
+ Generate Image
469
+ </button>
470
+ )}
471
+
472
+ {/* ===== Open output folder ===== */}
473
+ <button
474
+ onClick={() => openFolder(projectId, "output")}
475
+ 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"
476
+ >
477
+ {FolderIcon}
478
+ Open Output Folder
479
+ </button>
480
+
481
+ {/* ===== Error ===== */}
482
+ {store.fastImageEdit.error && (
483
+ <div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
484
+ {store.fastImageEdit.error}
485
+ </div>
486
+ )}
487
+
488
+ {/* ===== Logs ===== */}
489
+ {store.fastImageEdit.logs.length > 0 && (
490
+ <div className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto">
491
+ <p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
492
+ Logs
493
+ </p>
494
+ <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
495
+ {store.fastImageEdit.logs.join("")}
496
+ </pre>
497
+ </div>
498
+ )}
499
+
500
+ {/* ===== Result ===== */}
501
+ {store.fastImageEdit.result && (
502
+ <div>
503
+ <div className="flex items-center justify-between mb-2">
504
+ <label className="text-xs font-semibold text-ink-700 uppercase tracking-wider">
505
+ Generated Image
506
+ </label>
507
+ <button
508
+ onClick={() => store.clearFastImageEditResult()}
509
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border border-ink-200 text-red-600 hover:border-red-200 hover:bg-red-50 transition-colors"
510
+ >
511
+ {CloseIcon}
512
+ Remove
513
+ </button>
514
+ </div>
515
+ <div className="rounded-2xl overflow-hidden border border-ink-200 shadow-card inline-block">
516
+ <img
517
+ src={store.fastImageEdit.result}
518
+ alt="Generated"
519
+ className="max-w-full h-auto"
520
+ />
521
+ </div>
522
+ </div>
523
+ )}
524
+
525
+ {/* ===== Preview modal ===== */}
526
+ {preview && (
527
+ <div
528
+ className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
529
+ onClick={() => setPreview(null)}
530
+ >
531
+ <div
532
+ className="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center gap-3"
533
+ onClick={(e) => e.stopPropagation()}
534
+ >
535
+ <img
536
+ src={preview.url}
537
+ alt={preview.filename}
538
+ className="max-w-full max-h-[80vh] object-contain rounded-2xl shadow-2xl"
539
+ />
540
+ <span className="text-xs text-white/70 truncate max-w-full">
541
+ {preview.filename}
542
+ </span>
543
+ <button
544
+ onClick={() => setPreview(null)}
545
+ className="absolute -top-3 -right-3 flex items-center justify-center w-9 h-9 bg-white text-ink-700 rounded-full shadow-lg hover:bg-ink-200 transition-colors"
546
+ title="Close (Esc)"
547
+ >
548
+ {CloseIcon}
549
+ </button>
550
+ </div>
551
+ </div>
552
+ )}
553
+ </div>
554
+ );
555
+ }