@effectnode/media 0.4.0 → 0.6.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/agent-backend.d.ts +1 -1
- package/dist/backend/movie-backend/agent/agent-backend.js +169 -83
- package/dist/backend/movie-backend/agent/tools/index.js +0 -2
- package/dist/backend/movie-backend/core.js +1 -1
- package/dist/backend/movie-backend/generation-queue.js +107 -44
- package/dist/backend/movie-backend/render-media.js +28 -254
- package/frontend/index.html +6 -1
- package/frontend/public/lambobo.png +0 -0
- package/frontend/src/movie-app/MediaStudio.tsx +6 -2
- package/frontend/src/movie-app/SetupPage.tsx +98 -63
- package/frontend/src/movie-app/components/Aurora.tsx +13 -0
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +5 -8
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +417 -205
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +350 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +40 -67
- package/frontend/src/movie-app/components/ProjectManager.tsx +80 -52
- package/frontend/src/movie-app/index.css +275 -8
- package/frontend/src/movie-app/index.html +1 -1
- package/frontend/src/movie-app/stores/aiModelStore.ts +172 -0
- package/frontend/src/movie-app/stores/generationStore.ts +2 -3
- package/frontend/src/movie-app/stores/movieStudioStore.ts +72 -3
- package/package.json +2 -2
- package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +0 -664
- package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +0 -881
|
@@ -1,664 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
useCharacterStore,
|
|
4
|
-
type Character,
|
|
5
|
-
} from "../../stores/characterStore";
|
|
6
|
-
import {
|
|
7
|
-
useGenerationStore,
|
|
8
|
-
type ProjectImage,
|
|
9
|
-
} from "../../stores/generationStore";
|
|
10
|
-
import CropTool from "./CropTool";
|
|
11
|
-
import CharacterSheet from "./CharacterSheet";
|
|
12
|
-
|
|
13
|
-
const API_BASE = `http://localhost:${(window as any).PORT}`;
|
|
14
|
-
|
|
15
|
-
/** Fetch an image URL and return it as a data URL (avoids canvas tainting). */
|
|
16
|
-
async function urlToDataUrl(url: string): Promise<string> {
|
|
17
|
-
const res = await fetch(url);
|
|
18
|
-
if (!res.ok) throw new Error(`Failed to load image (${res.status})`);
|
|
19
|
-
const blob = await res.blob();
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const reader = new FileReader();
|
|
22
|
-
reader.onload = () => resolve(reader.result as string);
|
|
23
|
-
reader.onerror = () => reject(new Error("Failed to read image"));
|
|
24
|
-
reader.readAsDataURL(blob);
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface Props {
|
|
29
|
-
projectId: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function CharacterCard({ imageUrl, name }: { imageUrl: string; name: string }) {
|
|
33
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
34
|
-
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
const canvas = canvasRef.current;
|
|
37
|
-
if (!canvas) return;
|
|
38
|
-
const ctx = canvas.getContext("2d");
|
|
39
|
-
if (!ctx) return;
|
|
40
|
-
|
|
41
|
-
const W = 160;
|
|
42
|
-
const H = 160;
|
|
43
|
-
const BAR = 18;
|
|
44
|
-
const img = new Image();
|
|
45
|
-
img.onload = () => {
|
|
46
|
-
ctx.clearRect(0, 0, W, H + BAR);
|
|
47
|
-
const scale = Math.max(W / img.width, H / img.height);
|
|
48
|
-
const dw = img.width * scale;
|
|
49
|
-
const dh = img.height * scale;
|
|
50
|
-
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
51
|
-
ctx.fillStyle = "rgba(0,0,0,0.65)";
|
|
52
|
-
ctx.fillRect(0, H, W, BAR);
|
|
53
|
-
ctx.fillStyle = "#fff";
|
|
54
|
-
ctx.font = "11px sans-serif";
|
|
55
|
-
ctx.textAlign = "center";
|
|
56
|
-
ctx.textBaseline = "middle";
|
|
57
|
-
ctx.fillText(name, W / 2, H + BAR / 2);
|
|
58
|
-
};
|
|
59
|
-
img.src = imageUrl;
|
|
60
|
-
}, [imageUrl, name]);
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<canvas
|
|
64
|
-
ref={canvasRef}
|
|
65
|
-
width={160}
|
|
66
|
-
height={178}
|
|
67
|
-
className="rounded-xl border border-ink-200"
|
|
68
|
-
/>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export default function CharactersTab({ projectId }: Props) {
|
|
73
|
-
const characterStore = useCharacterStore();
|
|
74
|
-
const gen = useGenerationStore();
|
|
75
|
-
|
|
76
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
77
|
-
const editFileInputRef = useRef<HTMLInputElement>(null);
|
|
78
|
-
|
|
79
|
-
const [name, setName] = useState("");
|
|
80
|
-
const [pendingImage, setPendingImage] = useState<string | null>(null);
|
|
81
|
-
const [editingId, setEditingId] = useState<string | null>(null);
|
|
82
|
-
const [editName, setEditName] = useState("");
|
|
83
|
-
const [editImage, setEditImage] = useState<string | null>(null);
|
|
84
|
-
const [uploading, setUploading] = useState(false);
|
|
85
|
-
const [error, setError] = useState<string | null>(null);
|
|
86
|
-
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
87
|
-
const [showImageModal, setShowImageModal] = useState(false);
|
|
88
|
-
const [previewCharacter, setPreviewCharacter] = useState<Character | null>(
|
|
89
|
-
null,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
characterStore.fetchCharacters(projectId);
|
|
94
|
-
gen.fetchProjectImages(projectId);
|
|
95
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
-
}, [projectId]);
|
|
97
|
-
|
|
98
|
-
// Close any open modal with the Escape key
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
101
|
-
if (e.key !== "Escape") return;
|
|
102
|
-
setShowImageModal(false);
|
|
103
|
-
setPreviewCharacter(null);
|
|
104
|
-
setConfirmDeleteId(null);
|
|
105
|
-
};
|
|
106
|
-
window.addEventListener("keydown", onKeyDown);
|
|
107
|
-
return () => window.removeEventListener("keydown", onKeyDown);
|
|
108
|
-
}, []);
|
|
109
|
-
|
|
110
|
-
const editing =
|
|
111
|
-
characterStore.characters.find((c) => c.id === editingId) ?? null;
|
|
112
|
-
|
|
113
|
-
const charUrl = (filename: string): string | null => {
|
|
114
|
-
const img = gen.projectImages.find((i) => i.filename === filename);
|
|
115
|
-
if (!img) return null;
|
|
116
|
-
return img.url.startsWith("http") ? img.url : `${API_BASE}${img.url}`;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const sheetItems = characterStore.characters
|
|
120
|
-
.map((c) => {
|
|
121
|
-
const url = charUrl(c.filename);
|
|
122
|
-
return url ? { id: c.id, name: c.name, url } : null;
|
|
123
|
-
})
|
|
124
|
-
.filter(
|
|
125
|
-
(x): x is { id: string; name: string; url: string } => x !== null,
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
const uploadImage = async (dataUrl: string): Promise<string | null> => {
|
|
129
|
-
const res = await fetch(`${API_BASE}/api/upload/image`, {
|
|
130
|
-
method: "POST",
|
|
131
|
-
headers: { "Content-Type": "application/json" },
|
|
132
|
-
body: JSON.stringify({
|
|
133
|
-
image: dataUrl,
|
|
134
|
-
filename: `character-${Date.now()}.png`,
|
|
135
|
-
projectId,
|
|
136
|
-
}),
|
|
137
|
-
});
|
|
138
|
-
if (!res.ok) throw new Error(await res.text());
|
|
139
|
-
const data = await res.json();
|
|
140
|
-
return data.filename as string;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const finishCharacter = async (dataUrl: string) => {
|
|
144
|
-
setUploading(true);
|
|
145
|
-
setError(null);
|
|
146
|
-
try {
|
|
147
|
-
const filename = await uploadImage(dataUrl);
|
|
148
|
-
if (!filename) throw new Error("Upload failed");
|
|
149
|
-
await characterStore.createCharacter(projectId, name.trim(), filename);
|
|
150
|
-
await gen.fetchProjectImages(projectId);
|
|
151
|
-
setName("");
|
|
152
|
-
setPendingImage(null);
|
|
153
|
-
} catch (e) {
|
|
154
|
-
setError(String(e));
|
|
155
|
-
} finally {
|
|
156
|
-
setUploading(false);
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const startEdit = (c: Character) => {
|
|
161
|
-
setEditingId(c.id);
|
|
162
|
-
setEditName(c.name);
|
|
163
|
-
setEditImage(null);
|
|
164
|
-
setPendingImage(null);
|
|
165
|
-
setError(null);
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const cancelEdit = () => {
|
|
169
|
-
setEditingId(null);
|
|
170
|
-
setEditImage(null);
|
|
171
|
-
setError(null);
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
const saveEdit = async () => {
|
|
175
|
-
if (!editingId) return;
|
|
176
|
-
await characterStore.updateCharacter(editingId, projectId, {
|
|
177
|
-
name: editName.trim(),
|
|
178
|
-
});
|
|
179
|
-
setEditingId(null);
|
|
180
|
-
setEditImage(null);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const handleEditFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
184
|
-
const file = e.target.files?.[0];
|
|
185
|
-
if (!file) return;
|
|
186
|
-
const reader = new FileReader();
|
|
187
|
-
reader.onload = () => {
|
|
188
|
-
setEditImage(reader.result as string);
|
|
189
|
-
setError(null);
|
|
190
|
-
};
|
|
191
|
-
reader.readAsDataURL(file);
|
|
192
|
-
e.target.value = "";
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const handleCropApply = async (dataUrl: string) => {
|
|
196
|
-
if (editImage) {
|
|
197
|
-
setUploading(true);
|
|
198
|
-
setError(null);
|
|
199
|
-
try {
|
|
200
|
-
const filename = await uploadImage(dataUrl);
|
|
201
|
-
if (!filename) throw new Error("Upload failed");
|
|
202
|
-
if (editingId) {
|
|
203
|
-
await characterStore.updateCharacter(editingId, projectId, {
|
|
204
|
-
filename,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
await gen.fetchProjectImages(projectId);
|
|
208
|
-
setEditImage(null);
|
|
209
|
-
} catch (e) {
|
|
210
|
-
setError(String(e));
|
|
211
|
-
} finally {
|
|
212
|
-
setUploading(false);
|
|
213
|
-
}
|
|
214
|
-
} else {
|
|
215
|
-
await finishCharacter(dataUrl);
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const handleCropCancel = () => {
|
|
220
|
-
setPendingImage(null);
|
|
221
|
-
setEditImage(null);
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
225
|
-
const file = e.target.files?.[0];
|
|
226
|
-
if (!file) return;
|
|
227
|
-
const reader = new FileReader();
|
|
228
|
-
reader.onload = () => {
|
|
229
|
-
setPendingImage(reader.result as string);
|
|
230
|
-
setError(null);
|
|
231
|
-
};
|
|
232
|
-
reader.readAsDataURL(file);
|
|
233
|
-
e.target.value = "";
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const handleDelete = async () => {
|
|
237
|
-
if (!confirmDeleteId) return;
|
|
238
|
-
await characterStore.deleteCharacter(confirmDeleteId, projectId);
|
|
239
|
-
setConfirmDeleteId(null);
|
|
240
|
-
if (editingId === confirmDeleteId) {
|
|
241
|
-
setEditingId(null);
|
|
242
|
-
setEditImage(null);
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const selectProjectImage = async (img: ProjectImage) => {
|
|
247
|
-
setShowImageModal(false);
|
|
248
|
-
setError(null);
|
|
249
|
-
try {
|
|
250
|
-
const dataUrl = await urlToDataUrl(img.url);
|
|
251
|
-
setPendingImage(dataUrl);
|
|
252
|
-
} catch (e) {
|
|
253
|
-
setError(String(e));
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
// ========== SVG Icons ==========
|
|
258
|
-
|
|
259
|
-
const CharacterIcon = (
|
|
260
|
-
<svg
|
|
261
|
-
width="18"
|
|
262
|
-
height="18"
|
|
263
|
-
viewBox="0 0 24 24"
|
|
264
|
-
fill="none"
|
|
265
|
-
stroke="currentColor"
|
|
266
|
-
strokeWidth="2"
|
|
267
|
-
strokeLinecap="round"
|
|
268
|
-
strokeLinejoin="round"
|
|
269
|
-
>
|
|
270
|
-
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
271
|
-
<circle cx="12" cy="7" r="4" />
|
|
272
|
-
</svg>
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
const UploadIcon = (
|
|
276
|
-
<svg
|
|
277
|
-
width="14"
|
|
278
|
-
height="14"
|
|
279
|
-
viewBox="0 0 24 24"
|
|
280
|
-
fill="none"
|
|
281
|
-
stroke="currentColor"
|
|
282
|
-
strokeWidth="2"
|
|
283
|
-
strokeLinecap="round"
|
|
284
|
-
strokeLinejoin="round"
|
|
285
|
-
>
|
|
286
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
287
|
-
<polyline points="17 8 12 3 7 8" />
|
|
288
|
-
<line x1="12" y1="3" x2="12" y2="15" />
|
|
289
|
-
</svg>
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
const PencilIcon = (
|
|
293
|
-
<svg
|
|
294
|
-
width="12"
|
|
295
|
-
height="12"
|
|
296
|
-
viewBox="0 0 24 24"
|
|
297
|
-
fill="none"
|
|
298
|
-
stroke="currentColor"
|
|
299
|
-
strokeWidth="2"
|
|
300
|
-
strokeLinecap="round"
|
|
301
|
-
strokeLinejoin="round"
|
|
302
|
-
>
|
|
303
|
-
<path d="M12 20h9" />
|
|
304
|
-
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
305
|
-
</svg>
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
const TrashIcon = (
|
|
309
|
-
<svg
|
|
310
|
-
width="12"
|
|
311
|
-
height="12"
|
|
312
|
-
viewBox="0 0 24 24"
|
|
313
|
-
fill="none"
|
|
314
|
-
stroke="currentColor"
|
|
315
|
-
strokeWidth="2"
|
|
316
|
-
strokeLinecap="round"
|
|
317
|
-
strokeLinejoin="round"
|
|
318
|
-
>
|
|
319
|
-
<polyline points="3 6 5 6 21 6" />
|
|
320
|
-
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
321
|
-
</svg>
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
const ImagesIcon = (
|
|
325
|
-
<svg
|
|
326
|
-
width="14"
|
|
327
|
-
height="14"
|
|
328
|
-
viewBox="0 0 24 24"
|
|
329
|
-
fill="none"
|
|
330
|
-
stroke="currentColor"
|
|
331
|
-
strokeWidth="2"
|
|
332
|
-
strokeLinecap="round"
|
|
333
|
-
strokeLinejoin="round"
|
|
334
|
-
>
|
|
335
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
336
|
-
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
337
|
-
<polyline points="21 15 16 10 5 21" />
|
|
338
|
-
</svg>
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
const CloseIcon = (
|
|
342
|
-
<svg
|
|
343
|
-
width="16"
|
|
344
|
-
height="16"
|
|
345
|
-
viewBox="0 0 24 24"
|
|
346
|
-
fill="none"
|
|
347
|
-
stroke="currentColor"
|
|
348
|
-
strokeWidth="2"
|
|
349
|
-
strokeLinecap="round"
|
|
350
|
-
strokeLinejoin="round"
|
|
351
|
-
>
|
|
352
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
353
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
354
|
-
</svg>
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
const MaximizeIcon = (
|
|
358
|
-
<svg
|
|
359
|
-
width="12"
|
|
360
|
-
height="12"
|
|
361
|
-
viewBox="0 0 24 24"
|
|
362
|
-
fill="none"
|
|
363
|
-
stroke="currentColor"
|
|
364
|
-
strokeWidth="2"
|
|
365
|
-
strokeLinecap="round"
|
|
366
|
-
strokeLinejoin="round"
|
|
367
|
-
>
|
|
368
|
-
<polyline points="15 3 21 3 21 9" />
|
|
369
|
-
<polyline points="9 21 3 21 3 15" />
|
|
370
|
-
<line x1="21" y1="3" x2="14" y2="10" />
|
|
371
|
-
<line x1="3" y1="21" x2="10" y2="14" />
|
|
372
|
-
</svg>
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
return (
|
|
376
|
-
<div className="flex flex-col gap-7">
|
|
377
|
-
<div className="flex items-center gap-2">
|
|
378
|
-
<span className="text-tiffany-600">{CharacterIcon}</span>
|
|
379
|
-
<h2 className="text-base font-semibold text-ink-900">Characters</h2>
|
|
380
|
-
</div>
|
|
381
|
-
|
|
382
|
-
{/* Create / Edit panel */}
|
|
383
|
-
{!editing ? (
|
|
384
|
-
<div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3 bg-ink-100/60">
|
|
385
|
-
<input
|
|
386
|
-
type="text"
|
|
387
|
-
value={name}
|
|
388
|
-
onChange={(e) => setName(e.target.value)}
|
|
389
|
-
placeholder="Character name"
|
|
390
|
-
className="px-3 py-2 bg-white border border-ink-200 rounded-xl text-sm text-ink-900 placeholder-ink-500 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30"
|
|
391
|
-
/>
|
|
392
|
-
<div className="flex items-center gap-2">
|
|
393
|
-
<input
|
|
394
|
-
ref={fileInputRef}
|
|
395
|
-
type="file"
|
|
396
|
-
accept="image/*"
|
|
397
|
-
onChange={handleFileChange}
|
|
398
|
-
className="hidden"
|
|
399
|
-
/>
|
|
400
|
-
<button
|
|
401
|
-
onClick={() => fileInputRef.current?.click()}
|
|
402
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
|
|
403
|
-
>
|
|
404
|
-
{UploadIcon}
|
|
405
|
-
Upload Image
|
|
406
|
-
</button>
|
|
407
|
-
<button
|
|
408
|
-
onClick={() => setShowImageModal(true)}
|
|
409
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
|
|
410
|
-
>
|
|
411
|
-
{ImagesIcon}
|
|
412
|
-
Select Project Image
|
|
413
|
-
</button>
|
|
414
|
-
</div>
|
|
415
|
-
</div>
|
|
416
|
-
) : (
|
|
417
|
-
<div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3 bg-ink-100/60">
|
|
418
|
-
<input
|
|
419
|
-
type="text"
|
|
420
|
-
value={editName}
|
|
421
|
-
onChange={(e) => setEditName(e.target.value)}
|
|
422
|
-
placeholder="Character name"
|
|
423
|
-
className="px-3 py-2 bg-white border border-ink-200 rounded-xl text-sm text-ink-900 placeholder-ink-500 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30"
|
|
424
|
-
/>
|
|
425
|
-
{editing && charUrl(editing.filename) && (
|
|
426
|
-
<img
|
|
427
|
-
src={charUrl(editing.filename)!}
|
|
428
|
-
alt={editing.name}
|
|
429
|
-
className="w-16 h-16 rounded-xl object-cover border border-ink-200"
|
|
430
|
-
/>
|
|
431
|
-
)}
|
|
432
|
-
<div className="flex items-center gap-2">
|
|
433
|
-
<input
|
|
434
|
-
ref={editFileInputRef}
|
|
435
|
-
type="file"
|
|
436
|
-
accept="image/*"
|
|
437
|
-
onChange={handleEditFileChange}
|
|
438
|
-
className="hidden"
|
|
439
|
-
/>
|
|
440
|
-
<button
|
|
441
|
-
onClick={() => editFileInputRef.current?.click()}
|
|
442
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
|
|
443
|
-
>
|
|
444
|
-
{UploadIcon}
|
|
445
|
-
Replace Image
|
|
446
|
-
</button>
|
|
447
|
-
<button
|
|
448
|
-
onClick={saveEdit}
|
|
449
|
-
className="px-3 py-2 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
|
|
450
|
-
>
|
|
451
|
-
Save
|
|
452
|
-
</button>
|
|
453
|
-
<button
|
|
454
|
-
onClick={cancelEdit}
|
|
455
|
-
className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
|
|
456
|
-
>
|
|
457
|
-
Cancel
|
|
458
|
-
</button>
|
|
459
|
-
</div>
|
|
460
|
-
</div>
|
|
461
|
-
)}
|
|
462
|
-
|
|
463
|
-
{uploading && (
|
|
464
|
-
<p className="text-xs text-ink-600 italic">Saving character...</p>
|
|
465
|
-
)}
|
|
466
|
-
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
467
|
-
|
|
468
|
-
{/* Crop tool (create or edit) */}
|
|
469
|
-
{(pendingImage || editImage) && (
|
|
470
|
-
<CropTool
|
|
471
|
-
image={pendingImage ?? editImage!}
|
|
472
|
-
onCancel={handleCropCancel}
|
|
473
|
-
onApply={handleCropApply}
|
|
474
|
-
/>
|
|
475
|
-
)}
|
|
476
|
-
|
|
477
|
-
{/* Character grid */}
|
|
478
|
-
{characterStore.loading ? (
|
|
479
|
-
<p className="text-xs text-ink-500 italic text-center py-8">
|
|
480
|
-
Loading characters...
|
|
481
|
-
</p>
|
|
482
|
-
) : characterStore.characters.length === 0 ? (
|
|
483
|
-
<p className="text-xs text-ink-500 italic text-center py-8 border border-dashed border-ink-200 rounded-2xl">
|
|
484
|
-
No characters yet. Upload one.
|
|
485
|
-
</p>
|
|
486
|
-
) : (
|
|
487
|
-
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
|
488
|
-
{characterStore.characters.map((c) => {
|
|
489
|
-
const url = charUrl(c.filename);
|
|
490
|
-
return (
|
|
491
|
-
<div
|
|
492
|
-
key={c.id}
|
|
493
|
-
className="relative group cursor-pointer"
|
|
494
|
-
onClick={() => setPreviewCharacter(c)}
|
|
495
|
-
title="Preview image"
|
|
496
|
-
>
|
|
497
|
-
{url ? (
|
|
498
|
-
<CharacterCard imageUrl={url} name={c.name} />
|
|
499
|
-
) : (
|
|
500
|
-
<div className="w-[160px] h-[178px] rounded-xl border border-ink-200 bg-ink-100 flex items-center justify-center text-ink-500">
|
|
501
|
-
{CharacterIcon}
|
|
502
|
-
</div>
|
|
503
|
-
)}
|
|
504
|
-
<div
|
|
505
|
-
className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
506
|
-
onClick={(e) => e.stopPropagation()}
|
|
507
|
-
>
|
|
508
|
-
<button
|
|
509
|
-
onClick={() => setPreviewCharacter(c)}
|
|
510
|
-
className="w-6 h-6 rounded-full bg-black/50 text-ink-950 flex items-center justify-center hover:bg-tiffany-600 transition-colors"
|
|
511
|
-
title="Preview image"
|
|
512
|
-
>
|
|
513
|
-
{MaximizeIcon}
|
|
514
|
-
</button>
|
|
515
|
-
<button
|
|
516
|
-
onClick={() => startEdit(c)}
|
|
517
|
-
className="w-6 h-6 rounded-full bg-black/50 text-ink-950 flex items-center justify-center hover:bg-tiffany-600 transition-colors"
|
|
518
|
-
title="Edit character"
|
|
519
|
-
>
|
|
520
|
-
{PencilIcon}
|
|
521
|
-
</button>
|
|
522
|
-
<button
|
|
523
|
-
onClick={() => setConfirmDeleteId(c.id)}
|
|
524
|
-
className="w-6 h-6 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-red-600 transition-colors"
|
|
525
|
-
title="Delete character"
|
|
526
|
-
>
|
|
527
|
-
{TrashIcon}
|
|
528
|
-
</button>
|
|
529
|
-
</div>
|
|
530
|
-
</div>
|
|
531
|
-
);
|
|
532
|
-
})}
|
|
533
|
-
</div>
|
|
534
|
-
)}
|
|
535
|
-
|
|
536
|
-
{/* Character sheet (canvas 2d grid) */}
|
|
537
|
-
<div className="flex flex-col gap-3">
|
|
538
|
-
<div className="flex items-center gap-2">
|
|
539
|
-
<span className="text-tiffany-600">{ImagesIcon}</span>
|
|
540
|
-
<h3 className="text-sm font-semibold text-ink-900">
|
|
541
|
-
Character Sheet
|
|
542
|
-
</h3>
|
|
543
|
-
</div>
|
|
544
|
-
<CharacterSheet items={sheetItems} projectId={projectId} />
|
|
545
|
-
</div>
|
|
546
|
-
|
|
547
|
-
{/* Select project image modal */}
|
|
548
|
-
{showImageModal && (
|
|
549
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
|
550
|
-
<div className="bg-white rounded-3xl shadow-card p-6 w-[480px] max-w-full max-h-[80vh] flex flex-col">
|
|
551
|
-
<div className="flex items-center justify-between mb-3">
|
|
552
|
-
<h3 className="text-sm font-semibold text-ink-900">
|
|
553
|
-
Select Project Image
|
|
554
|
-
</h3>
|
|
555
|
-
<button
|
|
556
|
-
onClick={() => setShowImageModal(false)}
|
|
557
|
-
className="w-6 h-6 rounded-full text-tiffany-600 hover:bg-ink-200 hover:text-ink-800 flex items-center justify-center transition-colors"
|
|
558
|
-
title="Close"
|
|
559
|
-
>
|
|
560
|
-
{CloseIcon}
|
|
561
|
-
</button>
|
|
562
|
-
</div>
|
|
563
|
-
|
|
564
|
-
{gen.projectImagesLoading ? (
|
|
565
|
-
<p className="text-xs text-ink-500 italic text-center py-8">
|
|
566
|
-
Loading images...
|
|
567
|
-
</p>
|
|
568
|
-
) : gen.projectImages.length === 0 ? (
|
|
569
|
-
<p className="text-xs text-ink-500 italic text-center py-8 border border-dashed border-ink-200 rounded-2xl">
|
|
570
|
-
No project images yet. Upload or generate one first.
|
|
571
|
-
</p>
|
|
572
|
-
) : (
|
|
573
|
-
<div className="grid grid-cols-3 gap-2 overflow-y-auto pr-1">
|
|
574
|
-
{gen.projectImages.map((img) => (
|
|
575
|
-
<button
|
|
576
|
-
key={`${img.source}-${img.filename}`}
|
|
577
|
-
onClick={() => selectProjectImage(img)}
|
|
578
|
-
className="relative rounded-xl border border-ink-200 hover:border-tiffany-500 overflow-hidden transition-colors"
|
|
579
|
-
title={img.filename}
|
|
580
|
-
>
|
|
581
|
-
<img
|
|
582
|
-
src={img.url}
|
|
583
|
-
alt={img.filename}
|
|
584
|
-
className="aspect-square object-cover w-full"
|
|
585
|
-
/>
|
|
586
|
-
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1 py-0.5 text-[10px] text-ink-700 truncate text-center">
|
|
587
|
-
{img.filename}
|
|
588
|
-
</span>
|
|
589
|
-
</button>
|
|
590
|
-
))}
|
|
591
|
-
</div>
|
|
592
|
-
)}
|
|
593
|
-
</div>
|
|
594
|
-
</div>
|
|
595
|
-
)}
|
|
596
|
-
|
|
597
|
-
{/* Character image preview modal */}
|
|
598
|
-
{previewCharacter && (
|
|
599
|
-
<div
|
|
600
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-5"
|
|
601
|
-
onClick={() => setPreviewCharacter(null)}
|
|
602
|
-
>
|
|
603
|
-
<div
|
|
604
|
-
className="bg-white rounded-3xl shadow-card p-6 w-full max-w-2xl max-h-[85vh] flex flex-col"
|
|
605
|
-
onClick={(e) => e.stopPropagation()}
|
|
606
|
-
>
|
|
607
|
-
<div className="flex items-center justify-between mb-3">
|
|
608
|
-
<h3 className="text-sm font-semibold text-ink-900">
|
|
609
|
-
{previewCharacter.name}
|
|
610
|
-
</h3>
|
|
611
|
-
<button
|
|
612
|
-
onClick={() => setPreviewCharacter(null)}
|
|
613
|
-
className="w-6 h-6 rounded-full text-tiffany-600 hover:bg-ink-200 hover:text-ink-800 flex items-center justify-center transition-colors"
|
|
614
|
-
title="Close"
|
|
615
|
-
>
|
|
616
|
-
{CloseIcon}
|
|
617
|
-
</button>
|
|
618
|
-
</div>
|
|
619
|
-
|
|
620
|
-
<div className="flex-1 min-h-0 flex items-center justify-center bg-ink-50 rounded-2xl overflow-hidden">
|
|
621
|
-
{charUrl(previewCharacter.filename) ? (
|
|
622
|
-
<img
|
|
623
|
-
src={charUrl(previewCharacter.filename)!}
|
|
624
|
-
alt={previewCharacter.name}
|
|
625
|
-
className="max-h-[65vh] max-w-full object-contain"
|
|
626
|
-
/>
|
|
627
|
-
) : (
|
|
628
|
-
<div className="text-ink-500 py-16">{CharacterIcon}</div>
|
|
629
|
-
)}
|
|
630
|
-
</div>
|
|
631
|
-
</div>
|
|
632
|
-
</div>
|
|
633
|
-
)}
|
|
634
|
-
|
|
635
|
-
{/* Delete confirmation modal */}
|
|
636
|
-
{confirmDeleteId && (
|
|
637
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
|
638
|
-
<div className="bg-white rounded-3xl shadow-card p-6 w-80">
|
|
639
|
-
<h3 className="text-sm font-semibold text-ink-900 mb-2">
|
|
640
|
-
Delete Character
|
|
641
|
-
</h3>
|
|
642
|
-
<p className="text-xs text-ink-600 mb-4">
|
|
643
|
-
Are you sure you want to delete this character?
|
|
644
|
-
</p>
|
|
645
|
-
<div className="flex justify-end gap-2">
|
|
646
|
-
<button
|
|
647
|
-
onClick={() => setConfirmDeleteId(null)}
|
|
648
|
-
className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
|
|
649
|
-
>
|
|
650
|
-
Cancel
|
|
651
|
-
</button>
|
|
652
|
-
<button
|
|
653
|
-
onClick={handleDelete}
|
|
654
|
-
className="px-3 py-2 text-xs font-medium rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors"
|
|
655
|
-
>
|
|
656
|
-
Delete
|
|
657
|
-
</button>
|
|
658
|
-
</div>
|
|
659
|
-
</div>
|
|
660
|
-
</div>
|
|
661
|
-
)}
|
|
662
|
-
</div>
|
|
663
|
-
);
|
|
664
|
-
}
|