@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.
- package/bin/effectnode-media +2 -0
- package/dist/backend/index.d.ts +8 -0
- package/dist/backend/index.js +13 -0
- package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
- package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
- package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
- package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
- package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
- package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
- package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
- package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
- package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
- package/dist/backend/movie-backend/agent/tools/index.js +59 -0
- package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
- package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
- package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
- package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
- package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
- package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
- package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
- package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
- package/dist/backend/movie-backend/agent/tools/types.js +1 -0
- package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
- package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
- package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
- package/dist/backend/movie-backend/agent/workspace.js +124 -0
- package/dist/backend/movie-backend/core.d.ts +4 -0
- package/dist/backend/movie-backend/core.js +1167 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
- package/dist/backend/movie-backend/generation-queue.js +766 -0
- package/dist/backend/movie-backend/process.d.ts +21 -0
- package/dist/backend/movie-backend/process.js +98 -0
- package/dist/backend/movie-backend/render-media.d.ts +48 -0
- package/dist/backend/movie-backend/render-media.js +3311 -0
- package/dist/backend/routes/health.d.ts +1 -0
- package/dist/backend/routes/health.js +9 -0
- package/dist/backend/routes/index.d.ts +1 -0
- package/dist/backend/routes/index.js +11 -0
- package/dist/backend/routes/media.d.ts +1 -0
- package/dist/backend/routes/media.js +13 -0
- package/dist/backend/routes/projects.d.ts +8 -0
- package/dist/backend/routes/projects.js +68 -0
- package/dist/backend/routes/voice.d.ts +1 -0
- package/dist/backend/routes/voice.js +39 -0
- package/dist/backend/routes.d.ts +1 -0
- package/dist/backend/routes.js +20 -0
- package/dist/backend/server.d.ts +1 -0
- package/dist/backend/server.js +23 -0
- package/dist/backend/workspace.d.ts +5 -0
- package/dist/backend/workspace.js +27 -0
- package/dist/backend/ws/index.d.ts +3 -0
- package/dist/backend/ws/index.js +27 -0
- package/dist/backend/ws/messages.d.ts +8 -0
- package/dist/backend/ws/messages.js +21 -0
- package/dist/backend/ws.d.ts +3 -0
- package/dist/backend/ws.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +80 -0
- package/frontend/index.html +22 -0
- package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
- package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
- package/frontend/src/index.css +46 -0
- package/frontend/src/main.tsx +13 -0
- package/frontend/src/movie-app/AppRouter.tsx +14 -0
- package/frontend/src/movie-app/MediaStudio.tsx +9 -0
- package/frontend/src/movie-app/SetupPage.tsx +293 -0
- package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
- package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
- package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
- package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
- package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
- package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
- package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
- package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
- package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
- package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
- package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
- package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
- package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
- package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
- package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
- package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
- package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
- package/frontend/src/movie-app/index.css +46 -0
- package/frontend/src/movie-app/index.html +16 -0
- package/frontend/src/movie-app/lambobo.png +0 -0
- package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
- package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
- package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
- package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
- package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
- package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
- package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
- package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
- package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
- package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
- package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
- package/frontend/src/movie-app/stores/characterStore.ts +109 -0
- package/frontend/src/movie-app/stores/chatStore.ts +459 -0
- package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
- package/frontend/src/movie-app/stores/logStore.ts +25 -0
- package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
- package/frontend/src/movie-app/stores/projectStore.ts +124 -0
- package/frontend/src/movie-app/stores/queueStore.ts +214 -0
- package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
- package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
- package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
- package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
- package/frontend/src/vite-env.d.ts +1 -0
- package/frontend/tsconfig.json +19 -0
- package/package.json +65 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useBatchVideoStore, type BatchRowStatus } from "../../stores/batchVideoStore";
|
|
3
|
+
import { useGenerationStore } from "../../stores/generationStore";
|
|
4
|
+
import { useProjectStore } from "../../stores/projectStore";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
projectId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ========== SVG Icons ==========
|
|
11
|
+
|
|
12
|
+
const TableIcon = (
|
|
13
|
+
<svg
|
|
14
|
+
width="18"
|
|
15
|
+
height="18"
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
strokeWidth="2"
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
className="text-tiffany-600"
|
|
23
|
+
>
|
|
24
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
25
|
+
<line x1="3" y1="9" x2="21" y2="9" />
|
|
26
|
+
<line x1="3" y1="15" x2="21" y2="15" />
|
|
27
|
+
<line x1="9" y1="3" x2="9" y2="21" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const PlusIcon = (
|
|
32
|
+
<svg
|
|
33
|
+
width="14"
|
|
34
|
+
height="14"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeWidth="2"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
>
|
|
42
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
43
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const UploadIcon = (
|
|
48
|
+
<svg
|
|
49
|
+
width="14"
|
|
50
|
+
height="14"
|
|
51
|
+
viewBox="0 0 24 24"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
strokeWidth="2"
|
|
55
|
+
strokeLinecap="round"
|
|
56
|
+
strokeLinejoin="round"
|
|
57
|
+
>
|
|
58
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
59
|
+
<polyline points="17 8 12 3 7 8" />
|
|
60
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
61
|
+
</svg>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const ImageIcon = (
|
|
65
|
+
<svg
|
|
66
|
+
width="14"
|
|
67
|
+
height="14"
|
|
68
|
+
viewBox="0 0 24 24"
|
|
69
|
+
fill="none"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
strokeWidth="2"
|
|
72
|
+
strokeLinecap="round"
|
|
73
|
+
strokeLinejoin="round"
|
|
74
|
+
>
|
|
75
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
76
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
77
|
+
<polyline points="21 15 16 10 5 21" />
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const FolderIcon = (
|
|
82
|
+
<svg
|
|
83
|
+
width="16"
|
|
84
|
+
height="16"
|
|
85
|
+
viewBox="0 0 24 24"
|
|
86
|
+
fill="none"
|
|
87
|
+
stroke="currentColor"
|
|
88
|
+
strokeWidth="2"
|
|
89
|
+
strokeLinecap="round"
|
|
90
|
+
strokeLinejoin="round"
|
|
91
|
+
>
|
|
92
|
+
<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" />
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const FilmIcon = (
|
|
97
|
+
<svg
|
|
98
|
+
width="16"
|
|
99
|
+
height="16"
|
|
100
|
+
viewBox="0 0 24 24"
|
|
101
|
+
fill="none"
|
|
102
|
+
stroke="currentColor"
|
|
103
|
+
strokeWidth="2"
|
|
104
|
+
strokeLinecap="round"
|
|
105
|
+
strokeLinejoin="round"
|
|
106
|
+
>
|
|
107
|
+
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
|
|
108
|
+
<line x1="7" y1="2" x2="7" y2="22" />
|
|
109
|
+
<line x1="17" y1="2" x2="17" y2="22" />
|
|
110
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
111
|
+
<line x1="2" y1="7" x2="7" y2="7" />
|
|
112
|
+
<line x1="2" y1="17" x2="7" y2="17" />
|
|
113
|
+
<line x1="17" y1="17" x2="22" y2="17" />
|
|
114
|
+
<line x1="17" y1="7" x2="22" y2="7" />
|
|
115
|
+
</svg>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const CloseIcon = (
|
|
119
|
+
<svg
|
|
120
|
+
width="12"
|
|
121
|
+
height="12"
|
|
122
|
+
viewBox="0 0 24 24"
|
|
123
|
+
fill="none"
|
|
124
|
+
stroke="currentColor"
|
|
125
|
+
strokeWidth="2"
|
|
126
|
+
strokeLinecap="round"
|
|
127
|
+
strokeLinejoin="round"
|
|
128
|
+
>
|
|
129
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
130
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
131
|
+
</svg>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const SparkleIcon = (
|
|
135
|
+
<svg
|
|
136
|
+
width="16"
|
|
137
|
+
height="16"
|
|
138
|
+
viewBox="0 0 24 24"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
strokeWidth="2"
|
|
142
|
+
>
|
|
143
|
+
<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" />
|
|
144
|
+
</svg>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const CheckIcon = (
|
|
148
|
+
<svg
|
|
149
|
+
width="14"
|
|
150
|
+
height="14"
|
|
151
|
+
viewBox="0 0 24 24"
|
|
152
|
+
fill="none"
|
|
153
|
+
stroke="currentColor"
|
|
154
|
+
strokeWidth="2"
|
|
155
|
+
strokeLinecap="round"
|
|
156
|
+
strokeLinejoin="round"
|
|
157
|
+
>
|
|
158
|
+
<polyline points="20 6 9 17 4 12" />
|
|
159
|
+
</svg>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const SpinnerIcon = (
|
|
163
|
+
<svg
|
|
164
|
+
className="animate-spin"
|
|
165
|
+
width="14"
|
|
166
|
+
height="14"
|
|
167
|
+
viewBox="0 0 24 24"
|
|
168
|
+
fill="none"
|
|
169
|
+
stroke="currentColor"
|
|
170
|
+
strokeWidth="2"
|
|
171
|
+
>
|
|
172
|
+
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
173
|
+
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
174
|
+
</svg>
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// ========== Status badge ==========
|
|
178
|
+
|
|
179
|
+
function StatusBadge({ status }: { status: BatchRowStatus }) {
|
|
180
|
+
switch (status) {
|
|
181
|
+
case "uploading":
|
|
182
|
+
case "generating":
|
|
183
|
+
return (
|
|
184
|
+
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-ink-600">
|
|
185
|
+
{SpinnerIcon}
|
|
186
|
+
{status === "uploading" ? "Uploading" : "Generating"}
|
|
187
|
+
</span>
|
|
188
|
+
);
|
|
189
|
+
case "done":
|
|
190
|
+
return (
|
|
191
|
+
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600">
|
|
192
|
+
{CheckIcon}
|
|
193
|
+
Done
|
|
194
|
+
</span>
|
|
195
|
+
);
|
|
196
|
+
case "error":
|
|
197
|
+
return (
|
|
198
|
+
<span className="text-xs font-medium text-red-600">Error</span>
|
|
199
|
+
);
|
|
200
|
+
default:
|
|
201
|
+
return (
|
|
202
|
+
<span className="text-xs font-medium text-ink-500">—</span>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ========== Component ==========
|
|
208
|
+
|
|
209
|
+
export default function BatchVideoTab({ projectId }: Props) {
|
|
210
|
+
const store = useBatchVideoStore();
|
|
211
|
+
const genStore = useGenerationStore();
|
|
212
|
+
const { openFolder } = useProjectStore();
|
|
213
|
+
|
|
214
|
+
const logRef = useRef<HTMLDivElement | null>(null);
|
|
215
|
+
const stitchLogRef = useRef<HTMLDivElement | null>(null);
|
|
216
|
+
const [pickRowId, setPickRowId] = useState<string | null>(null);
|
|
217
|
+
|
|
218
|
+
// Restore persisted UI state (rows + settings) for the active project,
|
|
219
|
+
// re-loading whenever the project changes.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
store.hydrate(projectId);
|
|
222
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
223
|
+
}, [projectId]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (logRef.current) {
|
|
227
|
+
logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
228
|
+
}
|
|
229
|
+
}, [store.logs]);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (stitchLogRef.current) {
|
|
233
|
+
stitchLogRef.current.scrollTop = stitchLogRef.current.scrollHeight;
|
|
234
|
+
}
|
|
235
|
+
}, [store.stitchLogs]);
|
|
236
|
+
|
|
237
|
+
// Close the picker modal on Escape.
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!pickRowId) return;
|
|
240
|
+
const onKey = (e: KeyboardEvent) => {
|
|
241
|
+
if (e.key === "Escape") setPickRowId(null);
|
|
242
|
+
};
|
|
243
|
+
window.addEventListener("keydown", onKey);
|
|
244
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
245
|
+
}, [pickRowId]);
|
|
246
|
+
|
|
247
|
+
const openPicker = (rowId: string) => {
|
|
248
|
+
genStore.fetchProjectImages(projectId);
|
|
249
|
+
setPickRowId(rowId);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const handleImageSelect = async (
|
|
253
|
+
e: React.ChangeEvent<HTMLInputElement>,
|
|
254
|
+
rowId: string,
|
|
255
|
+
) => {
|
|
256
|
+
const file = e.target.files?.[0];
|
|
257
|
+
if (!file) return;
|
|
258
|
+
|
|
259
|
+
const reader = new FileReader();
|
|
260
|
+
reader.onload = async () => {
|
|
261
|
+
const base64 = reader.result as string;
|
|
262
|
+
const path = await store.uploadRowImage(
|
|
263
|
+
rowId,
|
|
264
|
+
base64,
|
|
265
|
+
file.name,
|
|
266
|
+
projectId,
|
|
267
|
+
);
|
|
268
|
+
if (path) {
|
|
269
|
+
genStore.fetchProjectImages(projectId);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
reader.readAsDataURL(file);
|
|
273
|
+
e.target.value = "";
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const readyCount = store.rows.filter(
|
|
277
|
+
(r) => r.prompt.trim() && r.imagePath,
|
|
278
|
+
).length;
|
|
279
|
+
|
|
280
|
+
const resultCount = store.rows.filter((r) => r.result).length;
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div className="flex flex-col gap-7">
|
|
284
|
+
<div className="flex items-center gap-2">
|
|
285
|
+
{TableIcon}
|
|
286
|
+
<h2 className="text-base font-semibold text-ink-900">
|
|
287
|
+
Batch Video Creation
|
|
288
|
+
</h2>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* Shared settings */}
|
|
292
|
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
293
|
+
{/* Duration */}
|
|
294
|
+
<div>
|
|
295
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
296
|
+
Duration (seconds)
|
|
297
|
+
</label>
|
|
298
|
+
<div className="flex flex-wrap gap-1.5">
|
|
299
|
+
{[0.5, 3, 5, 7.5, 10, 15, 20].map((d) => (
|
|
300
|
+
<button
|
|
301
|
+
key={d}
|
|
302
|
+
onClick={() => store.setDuration(d)}
|
|
303
|
+
disabled={store.running}
|
|
304
|
+
className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
|
|
305
|
+
store.duration === d
|
|
306
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
307
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
308
|
+
} disabled:opacity-50`}
|
|
309
|
+
>
|
|
310
|
+
{d}s
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Aspect ratio */}
|
|
317
|
+
<div>
|
|
318
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
319
|
+
Aspect Ratio
|
|
320
|
+
</label>
|
|
321
|
+
<div className="flex flex-wrap gap-1.5">
|
|
322
|
+
{(["1:1", "16:9", "9:16", "4:3", "3:4"] as const).map((ratio) => (
|
|
323
|
+
<button
|
|
324
|
+
key={ratio}
|
|
325
|
+
onClick={() => store.setAspectRatio(ratio)}
|
|
326
|
+
disabled={store.running}
|
|
327
|
+
className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
|
|
328
|
+
store.aspectRatio === ratio
|
|
329
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
330
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
331
|
+
} disabled:opacity-50`}
|
|
332
|
+
>
|
|
333
|
+
{ratio}
|
|
334
|
+
</button>
|
|
335
|
+
))}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{/* Resolution */}
|
|
340
|
+
<div>
|
|
341
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
342
|
+
Resolution
|
|
343
|
+
</label>
|
|
344
|
+
<div className="flex flex-wrap gap-1.5">
|
|
345
|
+
{(["320p", "480p", "576p", "640p", "720p", "1080p"] as const).map(
|
|
346
|
+
(res: any) => (
|
|
347
|
+
<button
|
|
348
|
+
key={res}
|
|
349
|
+
onClick={() => store.setResolution(res)}
|
|
350
|
+
disabled={store.running}
|
|
351
|
+
className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
|
|
352
|
+
store.resolution === res
|
|
353
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
354
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
355
|
+
} disabled:opacity-50`}
|
|
356
|
+
>
|
|
357
|
+
{res}
|
|
358
|
+
</button>
|
|
359
|
+
),
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* Quality */}
|
|
365
|
+
<div>
|
|
366
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
367
|
+
Quality
|
|
368
|
+
</label>
|
|
369
|
+
<div className="flex flex-wrap gap-1.5">
|
|
370
|
+
{(
|
|
371
|
+
[
|
|
372
|
+
{ label: "Low", value: "distilled" },
|
|
373
|
+
{ label: "Standard", value: "one-stage" },
|
|
374
|
+
{ label: "High", value: "two-stage" },
|
|
375
|
+
] as const
|
|
376
|
+
).map((q) => (
|
|
377
|
+
<button
|
|
378
|
+
key={q.value}
|
|
379
|
+
onClick={() => store.setMode(q.value)}
|
|
380
|
+
disabled={store.running}
|
|
381
|
+
className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
|
|
382
|
+
store.mode === q.value
|
|
383
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
384
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
385
|
+
} disabled:opacity-50`}
|
|
386
|
+
>
|
|
387
|
+
{q.label}
|
|
388
|
+
</button>
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* Table */}
|
|
395
|
+
<div>
|
|
396
|
+
<div className="flex items-center justify-between mb-2">
|
|
397
|
+
<label className="text-xs font-semibold text-ink-700 uppercase tracking-wider">
|
|
398
|
+
Batch Rows
|
|
399
|
+
</label>
|
|
400
|
+
<div className="flex items-center gap-1.5">
|
|
401
|
+
<button
|
|
402
|
+
onClick={store.clear}
|
|
403
|
+
disabled={store.running || store.stitching}
|
|
404
|
+
title="Clear all rows and settings"
|
|
405
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-red-600 hover:border-red-200 hover:bg-red-50 disabled:opacity-50"
|
|
406
|
+
>
|
|
407
|
+
{CloseIcon}
|
|
408
|
+
Clear
|
|
409
|
+
</button>
|
|
410
|
+
<button
|
|
411
|
+
onClick={store.addRow}
|
|
412
|
+
disabled={store.running}
|
|
413
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
|
|
414
|
+
>
|
|
415
|
+
{PlusIcon}
|
|
416
|
+
Add Row
|
|
417
|
+
</button>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="border border-ink-200 rounded-2xl overflow-hidden">
|
|
422
|
+
<div className="overflow-auto max-h-[480px]">
|
|
423
|
+
<table className="w-full text-xs">
|
|
424
|
+
<thead>
|
|
425
|
+
<tr className="bg-ink-50 border-b border-ink-200">
|
|
426
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-10">
|
|
427
|
+
#
|
|
428
|
+
</th>
|
|
429
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-36">
|
|
430
|
+
Starting Image
|
|
431
|
+
</th>
|
|
432
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 min-w-[260px]">
|
|
433
|
+
Prompt
|
|
434
|
+
</th>
|
|
435
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-28">
|
|
436
|
+
Status
|
|
437
|
+
</th>
|
|
438
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-40">
|
|
439
|
+
Result
|
|
440
|
+
</th>
|
|
441
|
+
<th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-24">
|
|
442
|
+
Actions
|
|
443
|
+
</th>
|
|
444
|
+
</tr>
|
|
445
|
+
</thead>
|
|
446
|
+
<tbody>
|
|
447
|
+
{store.rows.map((row, idx) => (
|
|
448
|
+
<tr
|
|
449
|
+
key={row.id}
|
|
450
|
+
className="border-b border-ink-200 last:border-b-0 align-top transition-colors hover:bg-ink-100/40"
|
|
451
|
+
>
|
|
452
|
+
<td className="px-3 py-2 text-ink-600/60 font-medium tabular-nums">
|
|
453
|
+
{idx + 1}
|
|
454
|
+
</td>
|
|
455
|
+
|
|
456
|
+
{/* Starting image */}
|
|
457
|
+
<td className="px-3 py-2">
|
|
458
|
+
<div className="flex flex-col items-start gap-1.5">
|
|
459
|
+
<div className="flex items-center gap-1">
|
|
460
|
+
<label className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-ink-200 bg-white text-ink-600 text-[11px] font-medium cursor-pointer hover:border-ink-300 hover:bg-ink-100 transition-colors">
|
|
461
|
+
{UploadIcon}
|
|
462
|
+
{row.imageFilename ? "Replace" : "Upload"}
|
|
463
|
+
<input
|
|
464
|
+
type="file"
|
|
465
|
+
accept="image/*"
|
|
466
|
+
onChange={(e) => handleImageSelect(e, row.id)}
|
|
467
|
+
disabled={store.running}
|
|
468
|
+
className="hidden"
|
|
469
|
+
/>
|
|
470
|
+
</label>
|
|
471
|
+
<button
|
|
472
|
+
onClick={() => openPicker(row.id)}
|
|
473
|
+
disabled={store.running}
|
|
474
|
+
title="Pick from project images"
|
|
475
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-ink-200 bg-white text-ink-600 text-[11px] font-medium hover:border-ink-300 hover:bg-ink-100 transition-colors disabled:opacity-50"
|
|
476
|
+
>
|
|
477
|
+
{ImageIcon}
|
|
478
|
+
Pick
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
{row.imageUrl ? (
|
|
482
|
+
<img
|
|
483
|
+
src={row.imageUrl}
|
|
484
|
+
alt={row.imageFilename || "Starting image"}
|
|
485
|
+
className="w-20 h-20 object-cover rounded-xl border border-ink-200"
|
|
486
|
+
/>
|
|
487
|
+
) : (
|
|
488
|
+
<span className="text-[10px] text-ink-500 italic">
|
|
489
|
+
No image
|
|
490
|
+
</span>
|
|
491
|
+
)}
|
|
492
|
+
{row.imageFilename && (
|
|
493
|
+
<span className="text-[10px] text-ink-600 truncate max-w-[120px]">
|
|
494
|
+
{row.imageFilename}
|
|
495
|
+
</span>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
</td>
|
|
499
|
+
|
|
500
|
+
{/* Prompt */}
|
|
501
|
+
<td className="px-3 py-2">
|
|
502
|
+
<textarea
|
|
503
|
+
value={row.prompt}
|
|
504
|
+
onChange={(e) =>
|
|
505
|
+
store.updatePrompt(row.id, e.target.value)
|
|
506
|
+
}
|
|
507
|
+
placeholder="Describe the video scene..."
|
|
508
|
+
rows={3}
|
|
509
|
+
disabled={store.running}
|
|
510
|
+
className="w-full px-2.5 py-1.5 bg-transparent border border-ink-200 rounded-xl text-ink-800 text-xs placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-1 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
|
|
511
|
+
/>
|
|
512
|
+
</td>
|
|
513
|
+
|
|
514
|
+
{/* Status */}
|
|
515
|
+
<td className="px-3 py-2">
|
|
516
|
+
<StatusBadge status={row.status} />
|
|
517
|
+
{row.status === "error" && row.error && (
|
|
518
|
+
<p className="text-[10px] text-red-600 mt-1 break-words max-w-[120px]">
|
|
519
|
+
{row.error}
|
|
520
|
+
</p>
|
|
521
|
+
)}
|
|
522
|
+
</td>
|
|
523
|
+
|
|
524
|
+
{/* Result */}
|
|
525
|
+
<td className="px-3 py-2">
|
|
526
|
+
{row.result ? (
|
|
527
|
+
<video
|
|
528
|
+
src={row.result}
|
|
529
|
+
controls
|
|
530
|
+
className="w-full max-w-[160px] h-auto rounded-xl bg-black"
|
|
531
|
+
/>
|
|
532
|
+
) : (
|
|
533
|
+
<span className="text-[10px] text-ink-500 italic">
|
|
534
|
+
—
|
|
535
|
+
</span>
|
|
536
|
+
)}
|
|
537
|
+
</td>
|
|
538
|
+
|
|
539
|
+
{/* Actions */}
|
|
540
|
+
<td className="px-3 py-2">
|
|
541
|
+
<div className="flex items-center gap-1">
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => store.generateRow(projectId, row.id)}
|
|
544
|
+
disabled={
|
|
545
|
+
store.running ||
|
|
546
|
+
!row.prompt.trim() ||
|
|
547
|
+
!row.imagePath
|
|
548
|
+
}
|
|
549
|
+
title="Generate this row"
|
|
550
|
+
className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-200 hover:border-ink-300 transition-colors disabled:opacity-40"
|
|
551
|
+
>
|
|
552
|
+
{SparkleIcon}
|
|
553
|
+
</button>
|
|
554
|
+
<button
|
|
555
|
+
onClick={() => store.removeRow(row.id)}
|
|
556
|
+
disabled={store.running}
|
|
557
|
+
title="Remove row"
|
|
558
|
+
className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors disabled:opacity-40"
|
|
559
|
+
>
|
|
560
|
+
{CloseIcon}
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</td>
|
|
564
|
+
</tr>
|
|
565
|
+
))}
|
|
566
|
+
</tbody>
|
|
567
|
+
</table>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
{store.rows.length === 0 && (
|
|
572
|
+
<p className="text-xs text-ink-500 italic py-6 text-center border border-dashed border-ink-200 rounded-2xl mt-2">
|
|
573
|
+
No rows yet. Click "Add Row" to get started.
|
|
574
|
+
</p>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
{/* Open output folder */}
|
|
579
|
+
<button
|
|
580
|
+
onClick={() => openFolder(projectId, "output")}
|
|
581
|
+
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"
|
|
582
|
+
>
|
|
583
|
+
{FolderIcon}
|
|
584
|
+
Open Output Folder
|
|
585
|
+
</button>
|
|
586
|
+
|
|
587
|
+
{/* Progress */}
|
|
588
|
+
{store.progress && (
|
|
589
|
+
<div className="flex items-center gap-3 p-4 bg-ink-50 border border-ink-200 rounded-2xl">
|
|
590
|
+
<span className="text-xs font-medium text-ink-700">
|
|
591
|
+
Batch Progress
|
|
592
|
+
</span>
|
|
593
|
+
<div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
|
|
594
|
+
<div
|
|
595
|
+
className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
|
|
596
|
+
style={{
|
|
597
|
+
width: `${(store.progress.current / store.progress.total) * 100}%`,
|
|
598
|
+
}}
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
601
|
+
<span className="text-xs font-semibold text-ink-700 tabular-nums">
|
|
602
|
+
{store.progress.current}/{store.progress.total}
|
|
603
|
+
</span>
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
|
|
607
|
+
{/* Logs */}
|
|
608
|
+
{store.logs.length > 0 && (
|
|
609
|
+
<div
|
|
610
|
+
ref={logRef}
|
|
611
|
+
className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
|
|
612
|
+
>
|
|
613
|
+
<p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
614
|
+
Progress Logs
|
|
615
|
+
</p>
|
|
616
|
+
<pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
|
|
617
|
+
{store.logs.join("\n")}
|
|
618
|
+
</pre>
|
|
619
|
+
</div>
|
|
620
|
+
)}
|
|
621
|
+
|
|
622
|
+
{/* Generate all / cancel */}
|
|
623
|
+
{store.running ? (
|
|
624
|
+
<button
|
|
625
|
+
onClick={store.cancel}
|
|
626
|
+
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 text-sm font-semibold rounded-2xl border border-red-200 transition-colors"
|
|
627
|
+
>
|
|
628
|
+
Cancel Batch
|
|
629
|
+
</button>
|
|
630
|
+
) : (
|
|
631
|
+
<button
|
|
632
|
+
onClick={() => store.generateAll(projectId)}
|
|
633
|
+
disabled={readyCount === 0}
|
|
634
|
+
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"
|
|
635
|
+
>
|
|
636
|
+
{SparkleIcon}
|
|
637
|
+
Generate All ({readyCount} videos)
|
|
638
|
+
</button>
|
|
639
|
+
)}
|
|
640
|
+
|
|
641
|
+
{readyCount === 0 && !store.running && (
|
|
642
|
+
<p className="text-xs text-tiffany-600 -mt-2">
|
|
643
|
+
Each row needs both a prompt and a starting image before it can be
|
|
644
|
+
generated.
|
|
645
|
+
</p>
|
|
646
|
+
)}
|
|
647
|
+
|
|
648
|
+
{/* ===== Stitch Videos ===== */}
|
|
649
|
+
<div className="pt-3 border-t border-ink-200">
|
|
650
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
651
|
+
Stitch Videos
|
|
652
|
+
</label>
|
|
653
|
+
|
|
654
|
+
{store.stitching ? (
|
|
655
|
+
<div className="flex items-center gap-2 w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
|
|
656
|
+
{SpinnerIcon}
|
|
657
|
+
<span className="text-sm font-medium text-ink-700">
|
|
658
|
+
Stitching...
|
|
659
|
+
</span>
|
|
660
|
+
</div>
|
|
661
|
+
) : (
|
|
662
|
+
<button
|
|
663
|
+
onClick={() => store.stitchVideos()}
|
|
664
|
+
disabled={resultCount < 2}
|
|
665
|
+
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"
|
|
666
|
+
>
|
|
667
|
+
{FilmIcon}
|
|
668
|
+
Stitch All Videos ({resultCount})
|
|
669
|
+
</button>
|
|
670
|
+
)}
|
|
671
|
+
|
|
672
|
+
{store.stitchError && (
|
|
673
|
+
<div className="mt-2 p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
|
|
674
|
+
{store.stitchError}
|
|
675
|
+
</div>
|
|
676
|
+
)}
|
|
677
|
+
|
|
678
|
+
{store.stitchLogs.length > 0 && (
|
|
679
|
+
<div
|
|
680
|
+
ref={stitchLogRef}
|
|
681
|
+
className="mt-2 p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
|
|
682
|
+
>
|
|
683
|
+
<p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
684
|
+
Stitch Logs
|
|
685
|
+
</p>
|
|
686
|
+
<pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
|
|
687
|
+
{store.stitchLogs.join("\n")}
|
|
688
|
+
</pre>
|
|
689
|
+
</div>
|
|
690
|
+
)}
|
|
691
|
+
|
|
692
|
+
{store.stitchResult && (
|
|
693
|
+
<div className="mt-3">
|
|
694
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
695
|
+
Stitched Video
|
|
696
|
+
</label>
|
|
697
|
+
<div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black w-full max-w-[500px]">
|
|
698
|
+
<video
|
|
699
|
+
src={store.stitchResult}
|
|
700
|
+
controls
|
|
701
|
+
className="w-full h-auto"
|
|
702
|
+
/>
|
|
703
|
+
</div>
|
|
704
|
+
<a
|
|
705
|
+
href={store.stitchResult}
|
|
706
|
+
download="stitched.mp4"
|
|
707
|
+
className="inline-flex items-center gap-1.5 mt-2 text-xs font-medium text-ink-600 hover:text-ink-900 transition-colors"
|
|
708
|
+
>
|
|
709
|
+
{UploadIcon}
|
|
710
|
+
Download stitched.mp4
|
|
711
|
+
</a>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
{/* ===== Project Images Picker Modal ===== */}
|
|
717
|
+
{pickRowId && (
|
|
718
|
+
<div
|
|
719
|
+
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
|
|
720
|
+
onClick={() => setPickRowId(null)}
|
|
721
|
+
>
|
|
722
|
+
<div
|
|
723
|
+
className="w-full max-w-3xl max-h-[80vh] flex flex-col bg-white rounded-3xl border border-ink-200 shadow-2xl overflow-hidden"
|
|
724
|
+
onClick={(e) => e.stopPropagation()}
|
|
725
|
+
>
|
|
726
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-ink-200">
|
|
727
|
+
<span className="text-sm font-semibold text-ink-900">
|
|
728
|
+
Pick Starting Image
|
|
729
|
+
</span>
|
|
730
|
+
<button
|
|
731
|
+
onClick={() => setPickRowId(null)}
|
|
732
|
+
className="flex items-center justify-center w-7 h-7 rounded-xl text-ink-600 hover:bg-ink-200 transition-colors"
|
|
733
|
+
title="Close (Esc)"
|
|
734
|
+
>
|
|
735
|
+
{CloseIcon}
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<div className="p-6 overflow-y-auto">
|
|
740
|
+
{genStore.projectImagesLoading ? (
|
|
741
|
+
<p className="text-xs text-ink-500 italic py-8 text-center">
|
|
742
|
+
Loading images...
|
|
743
|
+
</p>
|
|
744
|
+
) : genStore.projectImages.length === 0 ? (
|
|
745
|
+
<p className="text-xs text-ink-500 italic py-8 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
746
|
+
No images yet. Upload one first.
|
|
747
|
+
</p>
|
|
748
|
+
) : (
|
|
749
|
+
<div className="grid grid-cols-3 lg:grid-cols-5 gap-2">
|
|
750
|
+
{genStore.projectImages.map((img) => (
|
|
751
|
+
<button
|
|
752
|
+
key={`${img.source}-${img.filename}`}
|
|
753
|
+
onClick={() => {
|
|
754
|
+
store.setRowImage(pickRowId, img);
|
|
755
|
+
setPickRowId(null);
|
|
756
|
+
}}
|
|
757
|
+
className="relative rounded-xl border-2 border-ink-200 hover:border-ink-300 transition-all overflow-hidden"
|
|
758
|
+
>
|
|
759
|
+
<img
|
|
760
|
+
src={img.url}
|
|
761
|
+
alt={img.filename}
|
|
762
|
+
className="aspect-square object-cover object-center w-full"
|
|
763
|
+
/>
|
|
764
|
+
<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">
|
|
765
|
+
{img.filename}
|
|
766
|
+
</span>
|
|
767
|
+
</button>
|
|
768
|
+
))}
|
|
769
|
+
</div>
|
|
770
|
+
)}
|
|
771
|
+
</div>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
)}
|
|
775
|
+
</div>
|
|
776
|
+
);
|
|
777
|
+
}
|