返回 JoyAI-Echo
MemoryBankBoard.tsx
1 import {
2 Database,
3 GripVertical,
4 Images,
5 Info,
6 Music2,
7 Pause,
8 Play,
9 Plus,
10 RefreshCw,
11 Save,
12 Trash2,
13 Upload,
14 UserRound,
15 X,
16 } from "lucide-react";
17 import { useEffect, useMemo, useRef, useState } from "react";
18 import type { DragEvent } from "react";
19
20 import { ImageLightbox } from "@/components/ImageLightbox";
21 import type {
22 GenerationMemory,
23 MemoryAssetUpload,
24 MemoryReferenceType,
25 MemorySlotReference,
26 MemoryWorkspaceAsset,
27 UIImage,
28 WorkplaceShot,
29 } from "@/lib/types";
30 import { cn } from "@/lib/utils";
31
32 type MemoryFilter = "all" | "automatic" | "local";
33
34 interface MemoryBankBoardProps {
35 assets: MemoryWorkspaceAsset[];
36 shots: WorkplaceShot[];
37 busy?: boolean;
38 showSlots?: boolean;
39 onSaveAsset: (asset: MemoryAssetUpload) => Promise<void>;
40 onDeleteAsset: (assetId: string) => Promise<void>;
41 onApplySlots: (
42 shotId: number,
43 slots: MemorySlotReference[],
44 ) => Promise<void>;
45 }
46
47 const FILTERS: Array<{ id: MemoryFilter; label: string }> = [
48 { id: "all", label: "All" },
49 { id: "automatic", label: "Auto" },
50 { id: "local", label: "Local" },
51 ];
52
53 const REFERENCE_TYPES: Array<{ value: MemoryReferenceType | ""; label: string }> = [
54 { value: "", label: "Unassigned" },
55 { value: "character", label: "Character" },
56 { value: "scene", label: "Scene reference" },
57 { value: "style", label: "Style reference" },
58 { value: "object", label: "Object reference" },
59 { value: "other", label: "Other" },
60 ];
61
62 const MAX_SLOTS = 7;
63 const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
64 const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
65 export const MEMORY_ASSET_DRAG_TYPE = "application/x-echo-memory-asset";
66 export const MEMORY_SLOT_DRAG_TYPE = "application/x-echo-memory-slot";
67
68 type DraftSlot = {
69 image_asset_id?: string;
70 audio_asset_id?: string;
71 };
72
73 export type MemoryAssetDrag = {
74 assetId: string;
75 mediaType: "image" | "audio";
76 };
77
78 const MEMORY_DRAG_TEXT_PREFIX = "echo-memory:";
79
80 export function writeMemoryAssetDrag(
81 dataTransfer: DataTransfer,
82 payload: MemoryAssetDrag,
83 ) {
84 const serialized = JSON.stringify(payload);
85 dataTransfer.effectAllowed = "copy";
86 dataTransfer.setData(MEMORY_ASSET_DRAG_TYPE, serialized);
87 // Chromium can omit custom MIME data in some nested draggable controls.
88 dataTransfer.setData("text/plain", `${MEMORY_DRAG_TEXT_PREFIX}${serialized}`);
89 }
90
91 export function readMemoryAssetDrag(
92 dataTransfer: DataTransfer,
93 ): MemoryAssetDrag | null {
94 const custom = dataTransfer.getData(MEMORY_ASSET_DRAG_TYPE);
95 const plain = dataTransfer.getData("text/plain");
96 const serialized = custom || (
97 plain.startsWith(MEMORY_DRAG_TEXT_PREFIX)
98 ? plain.slice(MEMORY_DRAG_TEXT_PREFIX.length)
99 : ""
100 );
101 if (!serialized) return null;
102 try {
103 const payload = JSON.parse(serialized) as MemoryAssetDrag;
104 if (
105 typeof payload.assetId !== "string" ||
106 !["image", "audio"].includes(payload.mediaType)
107 ) {
108 return null;
109 }
110 return payload;
111 } catch {
112 return null;
113 }
114 }
115
116 function refsAsDraftSlots(
117 refs: MemorySlotReference[],
118 ): DraftSlot[] {
119 return refs.slice(0, MAX_SLOTS).flatMap((ref) => {
120 const imageAssetId = ref.image_asset_id ?? ref.asset_id;
121 const audioAssetId = ref.audio_asset_id;
122 return imageAssetId || audioAssetId
123 ? [{
124 ...(imageAssetId ? { image_asset_id: imageAssetId } : {}),
125 ...(audioAssetId ? { audio_asset_id: audioAssetId } : {}),
126 }]
127 : [];
128 });
129 }
130
131 function compactSlotRefs(slots: DraftSlot[]): MemorySlotReference[] {
132 return slots.flatMap((slot) =>
133 slot?.image_asset_id
134 ? [{
135 image_asset_id: slot.image_asset_id,
136 ...(slot.audio_asset_id
137 ? { audio_asset_id: slot.audio_asset_id }
138 : {}),
139 }]
140 : [],
141 );
142 }
143
144 function refSignature(refs: MemorySlotReference[]): string {
145 return refs
146 .map((ref) =>
147 `${ref.image_asset_id ?? ref.asset_id ?? ""}:${ref.audio_asset_id ?? ""}`,
148 )
149 .join("|");
150 }
151
152 function fileAsDataUrl(file: File): Promise<string> {
153 return new Promise((resolve, reject) => {
154 const reader = new FileReader();
155 reader.onerror = () => reject(new Error(`Cannot read ${file.name}`));
156 reader.onload = () => resolve(String(reader.result ?? ""));
157 reader.readAsDataURL(file);
158 });
159 }
160
161 function nameWithoutExtension(name: string): string {
162 return name.replace(/\.[^.]+$/, "").trim() || "Local asset";
163 }
164
165 function appliedAssetId(
166 memory: GenerationMemory,
167 assets: MemoryWorkspaceAsset[],
168 ): string | null {
169 if (memory.workspace_asset_id) return memory.workspace_asset_id;
170 const sourceShotId = memory.metadata.source_shot_id;
171 const frameIndex = memory.metadata.frame_index;
172 return (
173 assets.find(
174 (asset) =>
175 asset.source === "automatic" &&
176 asset.memory_id === memory.id &&
177 (sourceShotId == null || asset.source_shot_id === sourceShotId) &&
178 (frameIndex == null || asset.frame_index === frameIndex),
179 )?.asset_id ?? null
180 );
181 }
182
183 export function MemoryBankBoard({
184 assets,
185 shots,
186 busy = false,
187 showSlots = true,
188 onSaveAsset,
189 onDeleteAsset,
190 onApplySlots,
191 }: MemoryBankBoardProps) {
192 const [filter, setFilter] = useState<MemoryFilter>("all");
193 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
194 const [targetShotId, setTargetShotId] = useState<number | null>(
195 shots[0]?.shot_id ?? null,
196 );
197 const [draftSlots, setDraftSlots] = useState<DraftSlot[]>([]);
198 const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
199 const [message, setMessage] = useState<string | null>(null);
200 const imageInputRef = useRef<HTMLInputElement>(null);
201 const audioInputRef = useRef<HTMLInputElement>(null);
202 const uploadTargetRef = useRef<string | null>(null);
203
204 useEffect(() => {
205 if (
206 targetShotId != null &&
207 shots.some((shot) => shot.shot_id === targetShotId)
208 ) {
209 return;
210 }
211 setTargetShotId(shots[0]?.shot_id ?? null);
212 }, [shots, targetShotId]);
213
214 const targetShot = shots.find((shot) => shot.shot_id === targetShotId);
215 const appliedRefs = useMemo<MemorySlotReference[]>(() => {
216 if (targetShot?.approved_memory_slot_refs) {
217 return targetShot.approved_memory_slot_refs;
218 }
219 return (targetShot?.memory_slots ?? targetShot?.generation_memories ?? [])
220 .reduce<MemorySlotReference[]>((refs, memory) => {
221 const imageAssetId = appliedAssetId(memory, assets);
222 if (!imageAssetId) return refs;
223 refs.push({
224 image_asset_id: imageAssetId,
225 ...(memory.metadata.audio_workspace_asset_id
226 ? { audio_asset_id: memory.metadata.audio_workspace_asset_id }
227 : {}),
228 });
229 return refs;
230 }, []);
231 }, [assets, targetShot?.approved_memory_slot_refs, targetShot?.generation_memories, targetShot?.memory_slots]);
232 const recommendedRefs = useMemo<MemorySlotReference[]>(() => {
233 const refs = targetShot?.recommended_memory_slot_refs ?? [];
234 if (refs.length > 0) return refs;
235 return (targetShot?.recommended_memory_slots ?? [])
236 .flatMap((memory) => {
237 const imageAssetId = appliedAssetId(memory, assets);
238 return imageAssetId ? [{ image_asset_id: imageAssetId }] : [];
239 });
240 }, [assets, targetShot?.recommended_memory_slot_refs, targetShot?.recommended_memory_slots]);
241 const serverDraftRefs = targetShot?.memory_slots_configured
242 ? appliedRefs
243 : recommendedRefs;
244 const serverDraftSignature = refSignature(serverDraftRefs);
245
246 useEffect(() => {
247 setDraftSlots(refsAsDraftSlots(serverDraftRefs));
248 setMessage(null);
249 // The signature changes only when the server-side draft changes.
250 // eslint-disable-next-line react-hooks/exhaustive-deps
251 }, [targetShotId, serverDraftSignature, targetShot?.memory_slots_configured]);
252
253 const automaticCount = assets.filter(
254 (asset) => asset.source === "automatic",
255 ).length;
256 const identityCount = assets.filter(
257 (asset) => asset.kind === "character",
258 ).length;
259 const localCount = assets.length - automaticCount;
260 const visible = useMemo(
261 () =>
262 filter === "all"
263 ? assets
264 : assets.filter((asset) => asset.source === filter),
265 [assets, filter],
266 );
267 const assetById = useMemo(
268 () => new Map(assets.map((asset) => [asset.asset_id, asset])),
269 [assets],
270 );
271 const draftRefs = compactSlotRefs(draftSlots);
272 const draftSignature = refSignature(draftRefs);
273 const appliedRefSignature = refSignature(appliedRefs);
274 const dirty = !targetShot?.memory_slots_configured || draftSignature !== appliedRefSignature;
275 const occupiedSlotCount = draftSlots.length;
276 const imageSlotCount = draftRefs.length;
277
278 const previewImages = useMemo<UIImage[]>(
279 () =>
280 assets
281 .filter((asset) => Boolean(asset.image?.url))
282 .map((asset) => ({
283 url: asset.image?.url,
284 name: asset.image?.name ?? asset.display_name,
285 })),
286 [assets],
287 );
288 const previewIndexByAssetId = useMemo(
289 () => new Map(
290 assets
291 .filter((asset) => Boolean(asset.image?.url))
292 .map((asset, index) => [asset.asset_id, index]),
293 ),
294 [assets],
295 );
296
297 const uploadFile = async (
298 mediaKind: "image" | "audio",
299 file: File | undefined,
300 ) => {
301 if (!file) return;
302 const targetId = uploadTargetRef.current;
303 uploadTargetRef.current = null;
304 const isImage = mediaKind === "image";
305 if (isImage && !file.type.startsWith("image/")) {
306 setMessage("Choose a PNG, JPEG, WebP, or GIF image.");
307 return;
308 }
309 if (!isImage && !file.type.startsWith("audio/")) {
310 setMessage("Choose an audio file.");
311 return;
312 }
313 if (file.size > (isImage ? MAX_IMAGE_BYTES : MAX_AUDIO_BYTES)) {
314 setMessage(
315 isImage
316 ? "Image must be 8 MB or smaller."
317 : "Audio must be 20 MB or smaller.",
318 );
319 return;
320 }
321 setMessage(null);
322 try {
323 const upload = {
324 data_url: await fileAsDataUrl(file),
325 name: file.name,
326 };
327 await onSaveAsset({
328 ...(targetId ? { asset_id: targetId } : {}),
329 ...(!targetId
330 ? { display_name: nameWithoutExtension(file.name) }
331 : {}),
332 [mediaKind]: upload,
333 });
334 setMessage(
335 targetId
336 ? `${mediaKind === "image" ? "Image" : "Audio"} replaced.`
337 : "Asset added.",
338 );
339 } catch (error) {
340 setMessage((error as Error).message);
341 }
342 };
343
344 const addImageToDraft = (assetId: string) => {
345 setMessage(null);
346 setDraftSlots((current) => {
347 if (
348 current.length >= MAX_SLOTS ||
349 current.some((slot) => slot.image_asset_id === assetId)
350 ) {
351 return current;
352 }
353 return [...current, { image_asset_id: assetId }];
354 });
355 };
356
357 const setSlotMedia = (
358 index: number,
359 mediaType: "image" | "audio",
360 assetId: string,
361 ) => {
362 setMessage(null);
363 setDraftSlots((current) => {
364 if (
365 mediaType === "image" &&
366 current.some(
367 (slot, slotIndex) =>
368 slotIndex !== index && slot.image_asset_id === assetId,
369 )
370 ) {
371 return current;
372 }
373 if (index === current.length) {
374 if (current.length >= MAX_SLOTS) return current;
375 return [...current, {
376 [mediaType === "image" ? "image_asset_id" : "audio_asset_id"]: assetId,
377 }];
378 }
379 if (index < 0 || index >= current.length) return current;
380 const next = [...current];
381 next[index] = {
382 ...(next[index] ?? {}),
383 [mediaType === "image" ? "image_asset_id" : "audio_asset_id"]: assetId,
384 };
385 return next;
386 });
387 };
388
389 const clearSlotMedia = (index: number, mediaType: "image" | "audio") => {
390 setDraftSlots((current) => {
391 const slot = current[index];
392 if (!slot) return current;
393 const nextSlot = { ...slot };
394 delete nextSlot[mediaType === "image" ? "image_asset_id" : "audio_asset_id"];
395 const next = [...current];
396 if (nextSlot.image_asset_id || nextSlot.audio_asset_id) {
397 next[index] = nextSlot;
398 } else {
399 next.splice(index, 1);
400 }
401 return next;
402 });
403 };
404
405 const moveSlot = (sourceIndex: number, targetIndex: number) => {
406 if (sourceIndex === targetIndex) return;
407 setDraftSlots((current) => {
408 if (
409 sourceIndex < 0 ||
410 sourceIndex >= current.length ||
411 targetIndex < 0 ||
412 targetIndex > current.length
413 ) {
414 return current;
415 }
416 const next = [...current];
417 const [slot] = next.splice(sourceIndex, 1);
418 next.splice(Math.min(targetIndex, next.length), 0, slot);
419 return next;
420 });
421 };
422
423 const readDragPayload = (event: DragEvent) => {
424 const slotText = event.dataTransfer.getData(MEMORY_SLOT_DRAG_TYPE);
425 if (slotText) {
426 const sourceIndex = Number(slotText);
427 return Number.isInteger(sourceIndex)
428 ? { type: "slot" as const, sourceIndex }
429 : null;
430 }
431 const asset = readMemoryAssetDrag(event.dataTransfer);
432 return asset ? { type: "asset" as const, ...asset } : null;
433 };
434
435 const dropOnSlot = (event: DragEvent, targetIndex: number) => {
436 event.preventDefault();
437 const payload = readDragPayload(event);
438 setDragOverIndex(null);
439 if (!payload) return;
440 if (payload.type === "slot") {
441 moveSlot(payload.sourceIndex, targetIndex);
442 return;
443 }
444 setSlotMedia(targetIndex, payload.mediaType, payload.assetId);
445 };
446
447 const applyDraft = async () => {
448 if (targetShotId == null) return;
449 setMessage(null);
450 try {
451 await onApplySlots(
452 targetShotId,
453 draftRefs,
454 );
455 setMessage(
456 `Applied ${draftRefs.length} slot${draftRefs.length === 1 ? "" : "s"} to Shot ${targetShotId}.`,
457 );
458 } catch (error) {
459 setMessage((error as Error).message);
460 }
461 };
462
463 return (
464 <section
465 aria-label="Memory bank"
466 className="shrink-0 border-y border-border/55 bg-foreground/[0.018]"
467 >
468 <input
469 ref={imageInputRef}
470 type="file"
471 accept="image/png,image/jpeg,image/webp,image/gif"
472 className="hidden"
473 onChange={(event) => {
474 void uploadFile("image", event.target.files?.[0]);
475 event.target.value = "";
476 }}
477 />
478 <input
479 ref={audioInputRef}
480 type="file"
481 accept="audio/*"
482 className="hidden"
483 onChange={(event) => {
484 void uploadFile("audio", event.target.files?.[0]);
485 event.target.value = "";
486 }}
487 />
488
489 <div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2.5">
490 <div className="flex min-w-0 items-center gap-2.5">
491 <span className="grid size-7 shrink-0 place-items-center rounded-md bg-foreground/[0.06] text-foreground/55">
492 <Database className="size-3.5" aria-hidden />
493 </span>
494 <div className="min-w-0">
495 <h3 className="text-xs font-semibold text-foreground/80">
496 Memory Workspace
497 </h3>
498 <p className="text-[10px] tabular-nums text-muted-foreground">
499 {identityCount} identities · {automaticCount} auto · {localCount} local · up to 7 slots
500 </p>
501 </div>
502 </div>
503 <div className="flex items-center gap-2">
504 <div
505 role="group"
506 aria-label="Filter memory workspace"
507 className="inline-flex rounded-md bg-foreground/[0.045] p-0.5"
508 >
509 {FILTERS.map((item) => (
510 <button
511 key={item.id}
512 type="button"
513 aria-pressed={filter === item.id}
514 onClick={() => {
515 setFilter(item.id);
516 setLightboxIndex(null);
517 }}
518 className={cn(
519 "h-6 rounded px-2 text-[10px] font-medium transition-colors",
520 filter === item.id
521 ? "bg-background text-foreground shadow-sm"
522 : "text-muted-foreground hover:text-foreground/75",
523 )}
524 >
525 {item.label}
526 </button>
527 ))}
528 </div>
529 <button
530 type="button"
531 disabled={busy}
532 onClick={() => {
533 uploadTargetRef.current = null;
534 imageInputRef.current?.click();
535 }}
536 className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-[10px] font-medium text-foreground/75 hover:bg-muted disabled:opacity-50"
537 >
538 <Upload className="size-3" aria-hidden />
539 Upload image
540 </button>
541 <button
542 type="button"
543 disabled={busy}
544 onClick={() => {
545 uploadTargetRef.current = null;
546 audioInputRef.current?.click();
547 }}
548 className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-[10px] font-medium text-foreground/75 hover:bg-muted disabled:opacity-50"
549 >
550 <Music2 className="size-3" aria-hidden />
551 Upload audio
552 </button>
553 </div>
554 </div>
555
556 {visible.length > 0 ? (
557 <div className="flex gap-2 overflow-x-auto px-4 pb-3 scrollbar-thin">
558 {visible.map((asset) => (
559 <MemoryAssetCard
560 key={asset.asset_id}
561 asset={asset}
562 selected={draftSlots.some(
563 (slot) => slot.image_asset_id === asset.asset_id,
564 )}
565 slotLimitReached={draftSlots.length >= MAX_SLOTS}
566 showAdd={showSlots}
567 busy={busy}
568 onPreview={() =>
569 asset.image?.url
570 ? setLightboxIndex(previewIndexByAssetId.get(asset.asset_id) ?? 0)
571 : undefined
572 }
573 onAdd={() => asset.image?.url ? addImageToDraft(asset.asset_id) : undefined}
574 onReplaceImage={() => {
575 uploadTargetRef.current = asset.asset_id;
576 imageInputRef.current?.click();
577 }}
578 onReplaceAudio={() => {
579 uploadTargetRef.current = asset.asset_id;
580 audioInputRef.current?.click();
581 }}
582 onRemoveAudio={async () => {
583 setMessage(null);
584 try {
585 await onSaveAsset({
586 asset_id: asset.asset_id,
587 remove_audio: true,
588 });
589 setMessage("Audio removed.");
590 } catch (error) {
591 setMessage((error as Error).message);
592 }
593 }}
594 onDelete={async () => {
595 setMessage(null);
596 try {
597 await onDeleteAsset(asset.asset_id);
598 setDraftSlots((current) => current.flatMap((slot) => {
599 const next = { ...slot };
600 if (next.image_asset_id === asset.asset_id) {
601 delete next.image_asset_id;
602 }
603 if (next.audio_asset_id === asset.asset_id) {
604 delete next.audio_asset_id;
605 }
606 return next.image_asset_id || next.audio_asset_id ? [next] : [];
607 }));
608 setMessage("Asset removed from the workspace.");
609 } catch (error) {
610 setMessage((error as Error).message);
611 }
612 }}
613 onSaveProfile={async ({ profileText, referenceType, referenceLabel }) => {
614 setMessage(null);
615 try {
616 await onSaveAsset({
617 asset_id: asset.asset_id,
618 profile_text: profileText,
619 reference_type: referenceType || null,
620 reference_label: referenceLabel,
621 identity_ids:
622 referenceType === "character" && referenceLabel
623 ? [referenceLabel]
624 : [],
625 });
626 setMessage("Asset reference and profile saved for generation.");
627 } catch (error) {
628 setMessage((error as Error).message);
629 }
630 }}
631 />
632 ))}
633 </div>
634 ) : (
635 <div className="flex h-20 items-center justify-center gap-2 px-4 pb-3 text-xs text-muted-foreground">
636 <Images className="size-4" aria-hidden />
637 No {filter === "all" ? "" : `${filter} `}assets yet
638 </div>
639 )}
640
641 {showSlots ? (
642 <div className="border-t border-border/55 px-4 py-3">
643 <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
644 <div className="flex items-center gap-2">
645 <label
646 htmlFor="memory-target-shot"
647 className="text-[10px] font-semibold text-foreground/70"
648 >
649 Assemble for
650 </label>
651 <select
652 id="memory-target-shot"
653 value={targetShotId ?? ""}
654 onChange={(event) => setTargetShotId(Number(event.target.value))}
655 className="h-7 rounded-md border border-border bg-background px-2 text-[10px] text-foreground"
656 >
657 {shots.map((shot) => (
658 <option key={shot.shot_id} value={shot.shot_id}>
659 Shot {shot.shot_id}
660 </option>
661 ))}
662 </select>
663 <span className="text-[10px] text-muted-foreground">
664 {occupiedSlotCount}/{MAX_SLOTS} positions · {imageSlotCount} ready
665 </span>
666 {!targetShot?.memory_slots_configured && recommendedRefs.length > 0 ? (
667 <span className="rounded bg-sky-500/10 px-1.5 py-0.5 text-[9px] font-medium text-sky-700 dark:text-sky-300">
668 Agent recommendation · review before applying
669 </span>
670 ) : null}
671 </div>
672 <button
673 type="button"
674 disabled={busy || targetShotId == null || !dirty}
675 onClick={() => void applyDraft()}
676 className="inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground px-3 text-[10px] font-medium text-background disabled:cursor-not-allowed disabled:opacity-40"
677 >
678 <Save className="size-3" aria-hidden />
679 Apply to Shot {targetShotId ?? "–"}
680 </button>
681 </div>
682
683 <p className="mb-2 text-[10px] text-muted-foreground">
684 Drag assets in to add them. Drag a slot to reorder.
685 </p>
686 <div className="overflow-x-auto pb-1 scrollbar-thin">
687 <ol
688 aria-label="Memory slots"
689 className="flex min-h-36 items-stretch gap-2"
690 >
691 {draftSlots.map((slot, index) => {
692 const imageAsset = slot.image_asset_id
693 ? assetById.get(slot.image_asset_id)
694 : undefined;
695 const audioAsset = slot.audio_asset_id
696 ? assetById.get(slot.audio_asset_id)
697 : undefined;
698 return (
699 <li
700 key={`${slot.image_asset_id ?? "_"}:${slot.audio_asset_id ?? "_"}:${index}`}
701 data-testid={`memory-slot-${index + 1}`}
702 aria-label={`Memory slot ${index + 1}`}
703 draggable
704 onDragStart={(event) => {
705 event.dataTransfer.effectAllowed = "move";
706 event.dataTransfer.setData(MEMORY_SLOT_DRAG_TYPE, String(index));
707 }}
708 onDragEnd={() => setDragOverIndex(null)}
709 onDragOver={(event) => {
710 event.preventDefault();
711 event.dataTransfer.dropEffect = Array.from(
712 event.dataTransfer.types,
713 ).includes(MEMORY_SLOT_DRAG_TYPE)
714 ? "move"
715 : "copy";
716 setDragOverIndex(index);
717 }}
718 onDragLeave={() => {
719 setDragOverIndex((current) => current === index ? null : current);
720 }}
721 onDrop={(event) => dropOnSlot(event, index)}
722 className={cn(
723 "group/slot relative w-28 shrink-0 cursor-grab overflow-hidden rounded-lg border bg-background transition-all active:cursor-grabbing",
724 dragOverIndex === index
725 ? "translate-y-[-2px] border-sky-500 bg-sky-500/[0.06] ring-2 ring-sky-500/20"
726 : "border-border/70",
727 )}
728 >
729 <div className="absolute left-1 top-1 z-10 flex items-center gap-0.5">
730 <span className="grid size-5 place-items-center rounded bg-black/60 text-[9px] font-semibold text-white tabular-nums">
731 {index + 1}
732 </span>
733 <span
734 aria-hidden
735 className="grid size-5 place-items-center rounded bg-black/60 text-white"
736 >
737 <GripVertical className="size-3" />
738 </span>
739 </div>
740 <button
741 type="button"
742 aria-label={`Clear memory slot ${index + 1}`}
743 title="Clear slot"
744 onClick={() => setDraftSlots((current) =>
745 current.filter((_, itemIndex) => itemIndex !== index),
746 )}
747 className="absolute right-1 top-1 z-10 grid size-5 place-items-center rounded bg-black/60 text-white opacity-0 transition-opacity group-hover/slot:opacity-100 focus:opacity-100"
748 >
749 <X className="size-3" aria-hidden />
750 </button>
751
752 <div className="relative aspect-[4/3] border-b border-dashed border-border/70 bg-muted/50">
753 {imageAsset?.image?.url ? (
754 <>
755 <button
756 type="button"
757 aria-label={`Preview slot ${index + 1} image`}
758 onClick={() => setLightboxIndex(
759 previewIndexByAssetId.get(imageAsset.asset_id) ?? 0,
760 )}
761 className="block size-full overflow-hidden"
762 >
763 <img
764 src={imageAsset.image.url}
765 alt={imageAsset.display_name}
766 className="size-full object-cover transition-transform hover:scale-[1.03]"
767 />
768 </button>
769 <button
770 type="button"
771 aria-label={`Remove image from slot ${index + 1}`}
772 title="Remove image"
773 onClick={() => clearSlotMedia(index, "image")}
774 className="absolute bottom-1 right-1 grid size-5 place-items-center rounded bg-black/60 text-white opacity-0 transition-opacity group-hover/slot:opacity-100 focus:opacity-100"
775 >
776 <X className="size-3" aria-hidden />
777 </button>
778 </>
779 ) : (
780 <div className="grid size-full place-items-center px-2 text-center text-[9px] text-muted-foreground">
781 <span>
782 <span className="mx-auto mb-1 grid size-7 place-items-center rounded-md border border-dashed border-current/50">
783 <Images className="size-3.5" aria-hidden />
784 </span>
785 Drop image
786 </span>
787 </div>
788 )}
789 </div>
790
791 <div className="relative flex h-14 items-center border-t-2 border-foreground/15 px-1.5">
792 {audioAsset?.audio?.url ? (
793 <>
794 <MemoryAudioWaveform
795 src={audioAsset.audio.url}
796 label={`Slot ${index + 1}: ${audioAsset.display_name}`}
797 compact
798 />
799 <button
800 type="button"
801 aria-label={`Remove audio from slot ${index + 1}`}
802 title="Remove audio"
803 onClick={() => clearSlotMedia(index, "audio")}
804 className="absolute right-1 top-1 grid size-4 place-items-center rounded bg-background/85 text-muted-foreground opacity-0 transition-opacity group-hover/slot:opacity-100 focus:opacity-100"
805 >
806 <X className="size-2.5" aria-hidden />
807 </button>
808 </>
809 ) : (
810 <div className="flex w-full items-center justify-center gap-1 text-[9px] text-muted-foreground">
811 <Music2 className="size-3" aria-hidden />
812 Drop audio
813 </div>
814 )}
815 </div>
816 </li>
817 );
818 })}
819 {draftSlots.length < MAX_SLOTS ? (
820 <li
821 data-testid="memory-slot-add"
822 aria-label="Add memory slot"
823 onDragOver={(event) => {
824 event.preventDefault();
825 event.dataTransfer.dropEffect = Array.from(
826 event.dataTransfer.types,
827 ).includes(MEMORY_SLOT_DRAG_TYPE)
828 ? "move"
829 : "copy";
830 setDragOverIndex(draftSlots.length);
831 }}
832 onDragLeave={() => {
833 setDragOverIndex((current) =>
834 current === draftSlots.length ? null : current,
835 );
836 }}
837 onDrop={(event) => dropOnSlot(event, draftSlots.length)}
838 className={cn(
839 "grid w-28 shrink-0 place-items-center rounded-lg border border-dashed text-center transition-all",
840 dragOverIndex === draftSlots.length
841 ? "scale-[1.02] border-sky-500 bg-sky-500/[0.08] text-sky-600 ring-2 ring-sky-500/20"
842 : "border-border/80 bg-foreground/[0.015] text-muted-foreground",
843 )}
844 >
845 <span className="px-3 text-[9px]">
846 <span className="mx-auto mb-1.5 grid size-8 place-items-center rounded-full border border-current/40">
847 <Plus className="size-4" aria-hidden />
848 </span>
849 Drop to add
850 </span>
851 </li>
852 ) : null}
853 </ol>
854 </div>
855 {message ? (
856 <p role="status" className="mt-2 text-[10px] text-muted-foreground">
857 {message}
858 </p>
859 ) : null}
860 </div>
861 ) : null}
862
863 <ImageLightbox
864 images={previewImages}
865 index={lightboxIndex}
866 onIndexChange={setLightboxIndex}
867 onOpenChange={(open) => {
868 if (!open) setLightboxIndex(null);
869 }}
870 />
871 </section>
872 );
873 }
874
875 function MemoryAssetCard({
876 asset,
877 selected,
878 slotLimitReached,
879 showAdd,
880 busy,
881 onPreview,
882 onAdd,
883 onReplaceImage,
884 onReplaceAudio,
885 onRemoveAudio,
886 onDelete,
887 onSaveProfile,
888 }: {
889 asset: MemoryWorkspaceAsset;
890 selected: boolean;
891 slotLimitReached: boolean;
892 showAdd: boolean;
893 busy: boolean;
894 onPreview: () => void;
895 onAdd: () => void;
896 onReplaceImage: () => void;
897 onReplaceAudio: () => void;
898 onRemoveAudio: () => Promise<void>;
899 onDelete: () => Promise<void>;
900 onSaveProfile: (details: {
901 profileText: string;
902 referenceType: MemoryReferenceType | "";
903 referenceLabel: string;
904 }) => Promise<void>;
905 }) {
906 const local = asset.source === "local";
907 const [detailsOpen, setDetailsOpen] = useState(false);
908 const [profileText, setProfileText] = useState(asset.profile_text ?? "");
909 const [referenceType, setReferenceType] = useState<MemoryReferenceType | "">(
910 asset.reference_type ?? "",
911 );
912 const [referenceLabel, setReferenceLabel] = useState(asset.reference_label ?? "");
913 useEffect(() => setProfileText(asset.profile_text ?? ""), [asset.profile_text]);
914 useEffect(() => setReferenceType(asset.reference_type ?? ""), [asset.reference_type]);
915 useEffect(() => setReferenceLabel(asset.reference_label ?? ""), [asset.reference_label]);
916 const detailsDirty =
917 profileText.trim() !== (asset.profile_text ?? "").trim()
918 || referenceType !== (asset.reference_type ?? "")
919 || referenceLabel.trim() !== (asset.reference_label ?? "").trim();
920 return (
921 <article
922 draggable={!busy && Boolean(asset.image?.url || asset.audio?.url)}
923 onDragStart={(event) => {
924 writeMemoryAssetDrag(event.dataTransfer, {
925 assetId: asset.asset_id,
926 mediaType: asset.image?.url ? "image" : "audio",
927 });
928 }}
929 title="Drag this asset into a Shot memory slot"
930 className="grid w-56 shrink-0 cursor-grab grid-cols-[7rem_1fr] self-start overflow-hidden rounded-lg border border-border/70 bg-background active:cursor-grabbing"
931 >
932 <button
933 type="button"
934 onClick={onPreview}
935 disabled={!asset.image?.url}
936 aria-label={asset.image?.url
937 ? `Preview ${asset.display_name} memory image`
938 : `${asset.display_name} audio asset`}
939 className="relative block aspect-square cursor-grab overflow-hidden bg-muted text-left active:cursor-grabbing disabled:cursor-default"
940 >
941 {asset.image?.url ? (
942 <img
943 draggable={false}
944 src={asset.image.url}
945 alt={`${asset.display_name} Memory`}
946 className="size-full object-cover"
947 />
948 ) : (
949 <span className="grid size-full place-items-center text-muted-foreground">
950 <Music2 className="size-8" aria-hidden />
951 </span>
952 )}
953 <span
954 className={cn(
955 "pointer-events-none absolute bottom-1 left-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium text-white",
956 local ? "bg-violet-700/90" : "bg-black/70",
957 )}
958 >
959 {local ? (
960 <Upload className="size-2.5" aria-hidden />
961 ) : asset.kind === "character" ? (
962 <UserRound className="size-2.5" aria-hidden />
963 ) : (
964 <Images className="size-2.5" aria-hidden />
965 )}
966 {local ? "Local" : "Auto"}
967 </span>
968 </button>
969 <div className="flex min-w-0 flex-col gap-2 p-2">
970 <div className="flex min-w-0 items-start gap-1">
971 <div
972 className="min-w-0 flex-1 truncate text-[11px] font-semibold text-foreground/80"
973 title={asset.display_name}
974 >
975 {asset.display_name}
976 </div>
977 <button
978 type="button"
979 aria-expanded={detailsOpen}
980 aria-label={`${detailsOpen ? "Hide" : "Show"} ${asset.display_name} details`}
981 title="Asset details"
982 onClick={() => setDetailsOpen((open) => !open)}
983 className={cn(
984 "grid size-6 shrink-0 place-items-center rounded border border-border text-muted-foreground transition-colors hover:text-foreground",
985 detailsOpen && "bg-muted text-foreground",
986 )}
987 >
988 <Info className="size-3" aria-hidden />
989 </button>
990 </div>
991 {asset.audio?.url ? (
992 <MemoryAudioWaveform
993 src={asset.audio.url}
994 label={asset.display_name}
995 draggable
996 onDragStart={(event) => {
997 event.stopPropagation();
998 writeMemoryAssetDrag(event.dataTransfer, {
999 assetId: asset.asset_id,
1000 mediaType: "audio",
1001 });
1002 }}
1003 />
1004 ) : (
1005 <span className="flex h-7 items-center gap-1 rounded border border-dashed border-border px-1.5 text-[9px] text-muted-foreground">
1006 <Music2 className="size-3" aria-hidden />
1007 No audio
1008 </span>
1009 )}
1010 {showAdd ? (
1011 <div className="mt-auto">
1012 <button
1013 type="button"
1014 disabled={selected || slotLimitReached || busy || !asset.image?.url}
1015 onClick={onAdd}
1016 className="inline-flex h-7 w-full items-center justify-center gap-1 rounded bg-foreground px-2 text-[9px] font-medium text-background disabled:opacity-35"
1017 >
1018 <Plus className="size-2.5" />
1019 {selected ? "Added" : asset.image?.url ? "Add slot" : "Audio only"}
1020 </button>
1021 </div>
1022 ) : null}
1023 </div>
1024
1025 {detailsOpen ? (
1026 <div className="col-span-2 border-t border-border/60 bg-foreground/[0.015] p-2">
1027 <div className="mb-1.5 flex items-center gap-1.5 text-[9px] text-muted-foreground">
1028 <span>{local ? "Local asset" : "Automatic asset"}</span>
1029 {asset.source_shot_id ? (
1030 <span>· Shot {String(asset.source_shot_id).padStart(3, "0")}</span>
1031 ) : null}
1032 </div>
1033 <div className="mb-1.5 grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] gap-1.5">
1034 <select
1035 aria-label={`Assign ${asset.display_name} reference type`}
1036 value={referenceType}
1037 onChange={(event) =>
1038 setReferenceType(event.target.value as MemoryReferenceType | "")
1039 }
1040 className="h-7 min-w-0 rounded border border-border bg-background px-1.5 text-[9px] text-foreground"
1041 >
1042 {REFERENCE_TYPES.map((option) => (
1043 <option key={option.value || "unassigned"} value={option.value}>
1044 {option.label}
1045 </option>
1046 ))}
1047 </select>
1048 <input
1049 aria-label={`Edit ${asset.display_name} reference label`}
1050 value={referenceLabel}
1051 onChange={(event) => setReferenceLabel(event.target.value)}
1052 placeholder="角色_A / 雨夜街道"
1053 maxLength={80}
1054 className="h-7 min-w-0 rounded border border-border bg-background px-1.5 text-[9px] text-foreground placeholder:text-muted-foreground/60"
1055 />
1056 </div>
1057 <textarea
1058 aria-label={`Edit ${asset.display_name} profile`}
1059 value={profileText}
1060 onChange={(event) => setProfileText(event.target.value)}
1061 placeholder="Describe identity, appearance, scene, motion, or audio cues…"
1062 rows={3}
1063 className="min-h-14 w-full resize-y rounded border border-border bg-background px-1.5 py-1 text-[9px] leading-3 text-foreground placeholder:text-muted-foreground/60"
1064 />
1065 <div className="mt-1.5 flex flex-wrap gap-1">
1066 <button
1067 type="button"
1068 disabled={busy || !detailsDirty}
1069 aria-label={`Save ${asset.display_name} profile`}
1070 onClick={() => void onSaveProfile({
1071 profileText: profileText.trim(),
1072 referenceType,
1073 referenceLabel: referenceLabel.trim(),
1074 })}
1075 className="inline-flex h-6 items-center gap-1 rounded border border-border px-2 text-[9px] text-muted-foreground hover:text-foreground disabled:opacity-35"
1076 >
1077 <Save className="size-2.5" />
1078 Save reference
1079 </button>
1080 {local ? (
1081 <>
1082 <button
1083 type="button"
1084 disabled={busy}
1085 aria-label={`Replace ${asset.display_name} image`}
1086 title="Replace image"
1087 onClick={onReplaceImage}
1088 className="grid size-6 place-items-center rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-35"
1089 >
1090 <RefreshCw className="size-2.5" />
1091 </button>
1092 <button
1093 type="button"
1094 disabled={busy}
1095 aria-label={`${asset.audio?.url ? "Replace" : "Add"} ${asset.display_name} audio`}
1096 title={asset.audio?.url ? "Replace audio" : "Add audio"}
1097 onClick={onReplaceAudio}
1098 className="grid size-6 place-items-center rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-35"
1099 >
1100 <Music2 className="size-2.5" />
1101 </button>
1102 {asset.audio?.url && asset.image?.url ? (
1103 <button
1104 type="button"
1105 disabled={busy}
1106 aria-label={`Remove ${asset.display_name} audio`}
1107 title="Remove audio"
1108 onClick={() => void onRemoveAudio()}
1109 className="grid size-6 place-items-center rounded border border-border text-muted-foreground hover:text-destructive disabled:opacity-35"
1110 >
1111 <Trash2 className="size-2.5" />
1112 </button>
1113 ) : null}
1114 <button
1115 type="button"
1116 disabled={busy}
1117 aria-label={`Delete ${asset.display_name}`}
1118 title="Delete asset"
1119 onClick={() => void onDelete()}
1120 className="grid size-6 place-items-center rounded border border-border text-muted-foreground hover:text-destructive disabled:opacity-35"
1121 >
1122 <Trash2 className="size-2.5" />
1123 </button>
1124 </>
1125 ) : null}
1126 </div>
1127 </div>
1128 ) : null}
1129 </article>
1130 );
1131 }
1132
1133 const WAVEFORM_BARS = [
1134 34, 58, 42, 78, 52, 86, 44, 66, 92, 48, 72, 38, 84, 56, 96, 62, 76, 46,
1135 88, 54, 68, 40, 82, 50,
1136 ];
1137
1138 export function MemoryAudioWaveform({
1139 src,
1140 label,
1141 compact = false,
1142 draggable = false,
1143 onDragStart,
1144 }: {
1145 src: string;
1146 label: string;
1147 compact?: boolean;
1148 draggable?: boolean;
1149 onDragStart?: (event: DragEvent<HTMLDivElement>) => void;
1150 }) {
1151 const audioRef = useRef<HTMLAudioElement>(null);
1152 const [playing, setPlaying] = useState(false);
1153
1154 const togglePlayback = () => {
1155 const audio = audioRef.current;
1156 if (!audio) return;
1157 if (audio.paused) {
1158 void audio.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
1159 } else {
1160 audio.pause();
1161 setPlaying(false);
1162 }
1163 };
1164
1165 return (
1166 <div
1167 draggable={draggable}
1168 onDragStart={onDragStart}
1169 title={draggable ? "Drag this audio into a slot" : undefined}
1170 className={cn(
1171 "min-w-0 flex-1 rounded border border-border/65 bg-foreground/[0.025]",
1172 draggable && "cursor-grab active:cursor-grabbing",
1173 )}
1174 >
1175 <button
1176 type="button"
1177 aria-label={`${playing ? "Pause" : "Play"} ${label} audio`}
1178 onClick={togglePlayback}
1179 className={cn(
1180 "flex w-full items-center gap-1 overflow-hidden px-1 text-muted-foreground hover:text-foreground",
1181 compact ? "h-8" : "h-7",
1182 )}
1183 >
1184 {playing ? (
1185 <Pause className="size-2.5 shrink-0" aria-hidden />
1186 ) : (
1187 <Play className="size-2.5 shrink-0" aria-hidden />
1188 )}
1189 <span
1190 aria-hidden
1191 className={cn(
1192 "flex flex-1 items-center justify-between gap-px",
1193 compact ? "h-5" : "h-4",
1194 )}
1195 >
1196 {WAVEFORM_BARS.map((height, index) => (
1197 <span
1198 key={index}
1199 className={cn(
1200 "w-px min-w-px rounded-full bg-current opacity-65",
1201 playing && "animate-pulse",
1202 )}
1203 style={{ height: `${height}%` }}
1204 />
1205 ))}
1206 </span>
1207 </button>
1208 <audio
1209 ref={audioRef}
1210 src={src}
1211 preload="none"
1212 onPlay={() => setPlaying(true)}
1213 onPause={() => setPlaying(false)}
1214 onEnded={() => setPlaying(false)}
1215 />
1216 </div>
1217 );
1218 }
1219
1219 lines Plain Text