返回 JoyAI-Echo
ShotMemorySlots.tsx
1 import { GripVertical, Images, LoaderCircle, Music2, Plus, Save, Scissors, X } from "lucide-react";
2 import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
3 import type { DragEvent } from "react";
4
5 import { ImageLightbox } from "@/components/ImageLightbox";
6 import { Button } from "@/components/ui/button";
7 import {
8 Dialog,
9 DialogContent,
10 DialogDescription,
11 DialogFooter,
12 DialogHeader,
13 DialogTitle,
14 } from "@/components/ui/dialog";
15 import type {
16 GenerationMemory,
17 MemoryReferenceType,
18 MemorySlotReference,
19 MemoryWorkspaceAsset,
20 ShotMemoryAssetCreate,
21 UIImage,
22 WorkplaceShot,
23 } from "@/lib/types";
24 import { cn } from "@/lib/utils";
25 import {
26 MEMORY_SLOT_DRAG_TYPE,
27 MemoryAudioWaveform,
28 readMemoryAssetDrag,
29 } from "./MemoryBankBoard";
30
31 const MAX_SLOTS = 7;
32
33 type DraftSlot = {
34 image_asset_id?: string;
35 audio_asset_id?: string;
36 };
37
38 interface ShotMemorySlotsProps {
39 assets: MemoryWorkspaceAsset[];
40 shot: WorkplaceShot;
41 conditionImage?: UIImage | null;
42 busy?: boolean;
43 onApplySlots: (
44 shotId: number,
45 slots: MemorySlotReference[],
46 ) => Promise<void>;
47 onCreateAsset?: (
48 shotId: number,
49 asset: ShotMemoryAssetCreate,
50 ) => Promise<void>;
51 }
52
53 export interface ShotMemorySlotsHandle {
54 /** Persist the visible draft before a generation or revision is submitted. */
55 applyPending: () => Promise<void>;
56 }
57
58 function appliedAssetId(
59 memory: GenerationMemory,
60 assets: MemoryWorkspaceAsset[],
61 ): string | null {
62 if (memory.workspace_asset_id) return memory.workspace_asset_id;
63 const sourceShotId = memory.metadata.source_shot_id;
64 const frameIndex = memory.metadata.frame_index;
65 return (
66 assets.find(
67 (asset) =>
68 asset.source === "automatic" &&
69 asset.memory_id === memory.id &&
70 (sourceShotId == null || asset.source_shot_id === sourceShotId) &&
71 (frameIndex == null || asset.frame_index === frameIndex),
72 )?.asset_id ?? null
73 );
74 }
75
76 function appliedRefsForShot(
77 shot: WorkplaceShot,
78 assets: MemoryWorkspaceAsset[],
79 ): MemorySlotReference[] {
80 if (shot.approved_memory_slot_refs) return shot.approved_memory_slot_refs;
81 return (shot.memory_slots ?? shot.generation_memories ?? []).flatMap(
82 (memory) => {
83 const imageAssetId = appliedAssetId(memory, assets);
84 if (!imageAssetId) return [];
85 return [{
86 image_asset_id: imageAssetId,
87 ...(memory.metadata.audio_workspace_asset_id
88 ? { audio_asset_id: memory.metadata.audio_workspace_asset_id }
89 : {}),
90 }];
91 },
92 );
93 }
94
95 function recommendedRefsForShot(
96 shot: WorkplaceShot,
97 assets: MemoryWorkspaceAsset[],
98 ): MemorySlotReference[] {
99 if (shot.recommended_memory_slot_refs !== undefined) {
100 return shot.recommended_memory_slot_refs;
101 }
102 return (shot.recommended_memory_slots ?? []).flatMap((memory) => {
103 const imageAssetId = appliedAssetId(memory, assets);
104 return imageAssetId ? [{ image_asset_id: imageAssetId }] : [];
105 });
106 }
107
108 function refsAsDraft(refs: MemorySlotReference[]): DraftSlot[] {
109 return refs.slice(0, MAX_SLOTS).flatMap((ref) => {
110 const imageAssetId = ref.image_asset_id ?? ref.asset_id;
111 return imageAssetId || ref.audio_asset_id
112 ? [{
113 ...(imageAssetId ? { image_asset_id: imageAssetId } : {}),
114 ...(ref.audio_asset_id
115 ? { audio_asset_id: ref.audio_asset_id }
116 : {}),
117 }]
118 : [];
119 });
120 }
121
122 function compactRefs(slots: DraftSlot[]): MemorySlotReference[] {
123 return slots.flatMap((slot) =>
124 slot.image_asset_id
125 ? [{
126 image_asset_id: slot.image_asset_id,
127 ...(slot.audio_asset_id
128 ? { audio_asset_id: slot.audio_asset_id }
129 : {}),
130 }]
131 : [],
132 );
133 }
134
135 function refsSignature(refs: MemorySlotReference[]): string {
136 return refs
137 .map((ref) =>
138 `${ref.image_asset_id ?? ref.asset_id ?? ""}:${ref.audio_asset_id ?? ""}`,
139 )
140 .join("|");
141 }
142
143 export const ShotMemorySlots = forwardRef<
144 ShotMemorySlotsHandle,
145 ShotMemorySlotsProps
146 >(function ShotMemorySlots({
147 assets,
148 shot,
149 conditionImage = null,
150 busy = false,
151 onApplySlots,
152 onCreateAsset,
153 }, ref) {
154 const appliedRefs = useMemo(
155 () => appliedRefsForShot(shot, assets),
156 [assets, shot],
157 );
158 const recommendedRefs = useMemo(
159 () => recommendedRefsForShot(shot, assets),
160 [assets, shot],
161 );
162 const serverRefs = shot.memory_slots_configured
163 ? appliedRefs
164 : recommendedRefs;
165 const serverSignature = refsSignature(serverRefs);
166 const [slots, setSlots] = useState<DraftSlot[]>(() => refsAsDraft(serverRefs));
167 const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
168 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
169 const [conditionLightboxOpen, setConditionLightboxOpen] = useState(false);
170 const [status, setStatus] = useState<string | null>(null);
171 const [referenceDialogOpen, setReferenceDialogOpen] = useState(false);
172 const sourceVideo = shot.video ?? null;
173 const savedFromShot = assets.filter(
174 (asset) => asset.provenance?.shot_id === shot.shot_id,
175 ).length;
176
177 useEffect(() => {
178 setSlots(refsAsDraft(serverRefs));
179 setStatus(null);
180 // Reset only when this shot's server-side memory draft changes.
181 // eslint-disable-next-line react-hooks/exhaustive-deps
182 }, [shot.shot_id, serverSignature, shot.memory_slots_configured]);
183
184 const assetById = useMemo(
185 () => new Map(assets.map((asset) => [asset.asset_id, asset])),
186 [assets],
187 );
188 const previewImages = useMemo<UIImage[]>(
189 () =>
190 assets
191 .filter((asset) => Boolean(asset.image?.url))
192 .map((asset) => ({
193 url: asset.image?.url,
194 name: asset.image?.name ?? asset.display_name,
195 })),
196 [assets],
197 );
198 const previewIndexByAssetId = useMemo(
199 () => new Map(
200 assets
201 .filter((asset) => Boolean(asset.image?.url))
202 .map((asset, index) => [asset.asset_id, index]),
203 ),
204 [assets],
205 );
206 const draftRefs = compactRefs(slots);
207 const dirty =
208 !shot.memory_slots_configured ||
209 refsSignature(draftRefs) !== refsSignature(appliedRefs);
210
211 const setSlotMedia = (
212 index: number,
213 mediaType: "image" | "audio",
214 assetId: string,
215 ) => {
216 setStatus(null);
217 setSlots((current) => {
218 if (
219 mediaType === "image" &&
220 current.some(
221 (slot, slotIndex) =>
222 slotIndex !== index && slot.image_asset_id === assetId,
223 )
224 ) {
225 return current;
226 }
227 if (index === current.length) {
228 if (current.length >= MAX_SLOTS) return current;
229 return [...current, {
230 [mediaType === "image" ? "image_asset_id" : "audio_asset_id"]:
231 assetId,
232 }];
233 }
234 if (index < 0 || index >= current.length) return current;
235 const next = [...current];
236 next[index] = {
237 ...next[index],
238 [mediaType === "image" ? "image_asset_id" : "audio_asset_id"]:
239 assetId,
240 };
241 return next;
242 });
243 };
244
245 const clearMedia = (index: number, mediaType: "image" | "audio") => {
246 setSlots((current) => {
247 const slot = current[index];
248 if (!slot) return current;
249 const nextSlot = { ...slot };
250 delete nextSlot[mediaType === "image" ? "image_asset_id" : "audio_asset_id"];
251 const next = [...current];
252 if (nextSlot.image_asset_id || nextSlot.audio_asset_id) {
253 next[index] = nextSlot;
254 } else {
255 next.splice(index, 1);
256 }
257 return next;
258 });
259 };
260
261 const moveSlot = (sourceIndex: number, targetIndex: number) => {
262 if (sourceIndex === targetIndex) return;
263 setSlots((current) => {
264 if (
265 sourceIndex < 0 ||
266 sourceIndex >= current.length ||
267 targetIndex < 0 ||
268 targetIndex > current.length
269 ) {
270 return current;
271 }
272 const next = [...current];
273 const [slot] = next.splice(sourceIndex, 1);
274 next.splice(Math.min(targetIndex, next.length), 0, slot);
275 return next;
276 });
277 };
278
279 const readDragPayload = (event: DragEvent) => {
280 const slotText = event.dataTransfer.getData(MEMORY_SLOT_DRAG_TYPE);
281 if (slotText) {
282 const sourceIndex = Number(slotText);
283 return Number.isInteger(sourceIndex)
284 ? { type: "slot" as const, sourceIndex }
285 : null;
286 }
287 const asset = readMemoryAssetDrag(event.dataTransfer);
288 return asset ? { type: "asset" as const, ...asset } : null;
289 };
290
291 const dropAt = (event: DragEvent, targetIndex: number) => {
292 event.preventDefault();
293 const payload = readDragPayload(event);
294 setDragOverIndex(null);
295 if (!payload) return;
296 if (payload.type === "slot") {
297 moveSlot(payload.sourceIndex, targetIndex);
298 } else {
299 setSlotMedia(targetIndex, payload.mediaType, payload.assetId);
300 }
301 };
302
303 const dragOver = (event: DragEvent, index: number) => {
304 event.preventDefault();
305 event.dataTransfer.dropEffect = Array.from(event.dataTransfer.types).includes(
306 MEMORY_SLOT_DRAG_TYPE,
307 )
308 ? "move"
309 : "copy";
310 setDragOverIndex(index);
311 };
312
313 const apply = async () => {
314 if (!dirty) return;
315 setStatus(null);
316 try {
317 await onApplySlots(shot.shot_id, draftRefs);
318 setStatus("Memory slots applied.");
319 } catch (error) {
320 setStatus((error as Error).message);
321 throw error;
322 }
323 };
324
325 useImperativeHandle(ref, () => ({ applyPending: apply }));
326
327 return (
328 <section
329 aria-label={`Memory slots for Shot ${shot.shot_id}`}
330 className="mb-2 rounded-xl border border-border/50 bg-foreground/[0.018] p-2.5"
331 >
332 <div className="mb-2 flex items-center justify-between gap-2">
333 <div className="flex min-w-0 items-center gap-2">
334 <span className="text-[10px] font-semibold text-foreground/70">
335 Shot {shot.shot_id} Inputs
336 </span>
337 <span className="text-[9px] tabular-nums text-muted-foreground">
338 {slots.length}/{MAX_SLOTS}
339 </span>
340 {!shot.memory_slots_configured && recommendedRefs.length > 0 ? (
341 <span className="rounded bg-sky-500/10 px-1.5 py-0.5 text-[9px] text-sky-700 dark:text-sky-300">
342 Agent draft
343 </span>
344 ) : null}
345 </div>
346 <div className="flex items-center gap-1.5">
347 {sourceVideo?.url && onCreateAsset ? (
348 <button
349 type="button"
350 disabled={busy}
351 onClick={() => setReferenceDialogOpen(true)}
352 className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-[9px] text-muted-foreground hover:text-foreground disabled:opacity-35"
353 >
354 <Scissors className="size-2.5" aria-hidden />
355 Save reference{savedFromShot > 0 ? ` · ${savedFromShot}` : ""}
356 </button>
357 ) : null}
358 <button
359 type="button"
360 disabled={busy || !dirty}
361 onClick={() => void apply().catch(() => undefined)}
362 className="inline-flex h-6 items-center gap-1 rounded-md bg-foreground px-2 text-[9px] font-medium text-background disabled:opacity-35"
363 >
364 <Save className="size-2.5" aria-hidden />
365 Apply
366 </button>
367 </div>
368 </div>
369
370 <div className="flex min-w-0 gap-2">
371 <div className="w-24 shrink-0">
372 <div className="mb-1 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">
373 Condition
374 </div>
375 <div className="aspect-[4/3] overflow-hidden rounded-lg border border-border/70 bg-muted/40">
376 {conditionImage?.url ? (
377 <button
378 type="button"
379 aria-label={`Preview Shot ${shot.shot_id} condition image`}
380 onClick={() => setConditionLightboxOpen(true)}
381 className="size-full"
382 >
383 <img
384 src={conditionImage.url}
385 alt={`Shot ${shot.shot_id} condition`}
386 className="size-full object-cover"
387 />
388 </button>
389 ) : (
390 <div className="grid size-full place-items-center px-2 text-center text-[8px] text-muted-foreground">
391 T2V · no condition
392 </div>
393 )}
394 </div>
395 </div>
396
397 <div className="min-w-0 flex-1">
398 <div className="mb-1 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">
399 Memory
400 </div>
401 <div className="overflow-x-auto pb-1 scrollbar-thin">
402 <ol aria-label={`Shot ${shot.shot_id} memory slots`} className="flex min-h-32 gap-2">
403 {slots.map((slot, index) => {
404 const imageAsset = slot.image_asset_id
405 ? assetById.get(slot.image_asset_id)
406 : undefined;
407 const audioAsset = slot.audio_asset_id
408 ? assetById.get(slot.audio_asset_id)
409 : undefined;
410 return (
411 <li
412 key={`${slot.image_asset_id ?? "_"}:${slot.audio_asset_id ?? "_"}:${index}`}
413 data-testid={`shot-${shot.shot_id}-memory-slot-${index + 1}`}
414 aria-label={`Shot ${shot.shot_id} memory slot ${index + 1}`}
415 draggable
416 onDragStart={(event) => {
417 event.dataTransfer.effectAllowed = "move";
418 event.dataTransfer.setData(MEMORY_SLOT_DRAG_TYPE, String(index));
419 }}
420 onDragEnd={() => setDragOverIndex(null)}
421 onDragOver={(event) => dragOver(event, index)}
422 onDragLeave={() =>
423 setDragOverIndex((current) => current === index ? null : current)
424 }
425 onDrop={(event) => dropAt(event, index)}
426 className={cn(
427 "group/slot relative w-24 shrink-0 cursor-grab overflow-hidden rounded-lg border bg-background transition-all active:cursor-grabbing",
428 dragOverIndex === index
429 ? "-translate-y-0.5 border-sky-500 ring-2 ring-sky-500/20"
430 : "border-border/70",
431 )}
432 >
433 <div className="pointer-events-none absolute left-1 top-1 z-10 flex items-center gap-0.5">
434 <span className="grid size-5 place-items-center rounded bg-black/60 text-[9px] font-semibold text-white">
435 {index + 1}
436 </span>
437 <span className="grid size-5 place-items-center rounded bg-black/60 text-white">
438 <GripVertical className="size-3" aria-hidden />
439 </span>
440 </div>
441 <button
442 type="button"
443 aria-label={`Clear Shot ${shot.shot_id} memory slot ${index + 1}`}
444 onClick={() =>
445 setSlots((current) =>
446 current.filter((_, currentIndex) => currentIndex !== index),
447 )
448 }
449 className="absolute right-1 top-1 z-10 grid size-5 place-items-center rounded bg-black/60 text-white opacity-0 group-hover/slot:opacity-100 focus:opacity-100"
450 >
451 <X className="size-3" aria-hidden />
452 </button>
453
454 <div className="relative aspect-[4/3] border-b border-dashed border-border/70 bg-muted/50">
455 {imageAsset?.image?.url ? (
456 <>
457 <button
458 type="button"
459 aria-label={`Preview Shot ${shot.shot_id} slot ${index + 1} image`}
460 onClick={() =>
461 setLightboxIndex(
462 previewIndexByAssetId.get(imageAsset.asset_id) ?? 0,
463 )
464 }
465 className="block size-full overflow-hidden"
466 >
467 <img
468 src={imageAsset.image.url}
469 alt={imageAsset.display_name}
470 className="size-full object-cover"
471 />
472 </button>
473 <button
474 type="button"
475 aria-label={`Remove image from Shot ${shot.shot_id} slot ${index + 1}`}
476 onClick={() => clearMedia(index, "image")}
477 className="absolute bottom-1 right-1 grid size-5 place-items-center rounded bg-black/60 text-white opacity-0 group-hover/slot:opacity-100 focus:opacity-100"
478 >
479 <X className="size-3" aria-hidden />
480 </button>
481 </>
482 ) : (
483 <div className="grid size-full place-items-center text-[9px] text-muted-foreground">
484 <span className="text-center">
485 <Images className="mx-auto mb-1 size-3.5" aria-hidden />
486 Drop image
487 </span>
488 </div>
489 )}
490 </div>
491
492 <div className="relative flex h-12 items-center border-t-2 border-foreground/15 px-1">
493 {audioAsset?.audio?.url ? (
494 <>
495 <MemoryAudioWaveform
496 src={audioAsset.audio.url}
497 label={`Shot ${shot.shot_id} slot ${index + 1}`}
498 compact
499 />
500 <button
501 type="button"
502 aria-label={`Remove audio from Shot ${shot.shot_id} slot ${index + 1}`}
503 onClick={() => clearMedia(index, "audio")}
504 className="absolute right-1 top-1 grid size-4 place-items-center rounded bg-background/85 text-muted-foreground opacity-0 group-hover/slot:opacity-100 focus:opacity-100"
505 >
506 <X className="size-2.5" aria-hidden />
507 </button>
508 </>
509 ) : (
510 <div className="flex w-full items-center justify-center gap-1 text-[9px] text-muted-foreground">
511 <Music2 className="size-3" aria-hidden />
512 Drop audio
513 </div>
514 )}
515 </div>
516 </li>
517 );
518 })}
519
520 {slots.length < MAX_SLOTS ? (
521 <li
522 data-testid={`shot-${shot.shot_id}-memory-slot-add`}
523 aria-label={`Add memory slot to Shot ${shot.shot_id}`}
524 onDragOver={(event) => dragOver(event, slots.length)}
525 onDragLeave={() =>
526 setDragOverIndex((current) =>
527 current === slots.length ? null : current,
528 )
529 }
530 onDrop={(event) => dropAt(event, slots.length)}
531 className={cn(
532 "grid w-24 shrink-0 place-items-center rounded-lg border border-dashed text-center transition-all",
533 dragOverIndex === slots.length
534 ? "scale-[1.02] border-sky-500 bg-sky-500/[0.08] text-sky-600 ring-2 ring-sky-500/20"
535 : "border-border/80 text-muted-foreground",
536 )}
537 >
538 <span className="px-2 text-[9px]">
539 <span className="mx-auto mb-1 grid size-7 place-items-center rounded-full border border-current/40">
540 <Plus className="size-3.5" aria-hidden />
541 </span>
542 Drop to add
543 </span>
544 </li>
545 ) : null}
546 </ol>
547 </div>
548 </div>
549 </div>
550 {status ? (
551 <p role="status" className="mt-1 text-[9px] text-muted-foreground">
552 {status}
553 </p>
554 ) : null}
555
556 <ImageLightbox
557 images={previewImages}
558 index={lightboxIndex}
559 onIndexChange={setLightboxIndex}
560 onOpenChange={(open) => {
561 if (!open) setLightboxIndex(null);
562 }}
563 />
564 <ImageLightbox
565 images={conditionImage?.url ? [conditionImage] : []}
566 index={conditionLightboxOpen ? 0 : null}
567 onIndexChange={() => {}}
568 onOpenChange={(open) => {
569 if (!open) setConditionLightboxOpen(false);
570 }}
571 />
572 {sourceVideo?.url && onCreateAsset ? (
573 <ShotReferenceDialog
574 open={referenceDialogOpen}
575 shotId={shot.shot_id}
576 videoUrl={sourceVideo.url}
577 onOpenChange={setReferenceDialogOpen}
578 onSave={(asset) => onCreateAsset(shot.shot_id, asset)}
579 />
580 ) : null}
581 </section>
582 );
583 });
584
585 const REFERENCE_TYPE_OPTIONS: Array<{
586 value: MemoryReferenceType;
587 label: string;
588 }> = [
589 { value: "character", label: "Character" },
590 { value: "scene", label: "Scene" },
591 { value: "style", label: "Style" },
592 { value: "object", label: "Object" },
593 { value: "other", label: "Other" },
594 ];
595
596 function formatClipTime(value: number): string {
597 const safe = Number.isFinite(value) ? Math.max(0, value) : 0;
598 return `${Math.floor(safe / 60)}:${(safe % 60).toFixed(2).padStart(5, "0")}`;
599 }
600
601 export function ShotReferenceDialog({
602 open,
603 shotId,
604 videoUrl,
605 onOpenChange,
606 onSave,
607 }: {
608 open: boolean;
609 shotId: number;
610 videoUrl: string;
611 onOpenChange: (open: boolean) => void;
612 onSave: (asset: ShotMemoryAssetCreate) => Promise<void>;
613 }) {
614 const videoRef = useRef<HTMLVideoElement>(null);
615 const [duration, setDuration] = useState(0);
616 const [frameTime, setFrameTime] = useState(0);
617 const [referenceType, setReferenceType] = useState<MemoryReferenceType>("character");
618 const [referenceLabel, setReferenceLabel] = useState("");
619 const [profileText, setProfileText] = useState("");
620 const [includeAudio, setIncludeAudio] = useState(true);
621 const [audioStart, setAudioStart] = useState(0);
622 const [audioEnd, setAudioEnd] = useState(0);
623 const [submitting, setSubmitting] = useState(false);
624
625 useEffect(() => {
626 if (!open) return;
627 setDuration(0);
628 setFrameTime(0);
629 setReferenceType("character");
630 setReferenceLabel("");
631 setProfileText("");
632 setIncludeAudio(true);
633 setAudioStart(0);
634 setAudioEnd(0);
635 setSubmitting(false);
636 }, [open]);
637
638 const seekFrame = (value: number) => {
639 const next = Math.min(Math.max(value, 0), duration || value);
640 setFrameTime(next);
641 if (videoRef.current) {
642 videoRef.current.currentTime = next;
643 videoRef.current.pause();
644 }
645 };
646
647 return (
648 <Dialog open={open} onOpenChange={onOpenChange}>
649 <DialogContent className="max-w-3xl">
650 <DialogHeader>
651 <DialogTitle>Save reference from Shot {shotId}</DialogTitle>
652 <DialogDescription>
653 Save any number of character, scene, style, or object references. Choose the frame and an optional audio clip independently.
654 </DialogDescription>
655 </DialogHeader>
656
657 <div className="grid gap-4 md:grid-cols-[minmax(0,1.3fr)_minmax(15rem,0.7fr)]">
658 <div className="space-y-3">
659 <video
660 ref={videoRef}
661 src={videoUrl}
662 controls
663 playsInline
664 preload="metadata"
665 className="aspect-video w-full rounded-lg border border-border bg-black object-contain"
666 onLoadedMetadata={(event) => {
667 const nextDuration = Number(event.currentTarget.duration || 0);
668 const middle = nextDuration / 2;
669 setDuration(nextDuration);
670 setFrameTime(middle);
671 setAudioStart(Math.max(0, middle - 1));
672 setAudioEnd(Math.min(nextDuration, middle + 1));
673 event.currentTarget.currentTime = middle;
674 }}
675 onTimeUpdate={(event) => setFrameTime(event.currentTarget.currentTime)}
676 onSeeked={(event) => setFrameTime(event.currentTarget.currentTime)}
677 />
678 <div className="space-y-1.5">
679 <div className="flex justify-between text-xs text-muted-foreground">
680 <span>Image frame {formatClipTime(frameTime)}</span>
681 <span>{formatClipTime(duration)}</span>
682 </div>
683 <input
684 aria-label="Reference image time"
685 type="range"
686 min={0}
687 max={Math.max(duration, 0.01)}
688 step={0.01}
689 value={Math.min(frameTime, Math.max(duration, 0.01))}
690 disabled={duration <= 0 || submitting}
691 onChange={(event) => seekFrame(Number(event.currentTarget.value))}
692 className="h-2 w-full cursor-pointer accent-foreground"
693 />
694 </div>
695 <label className="flex items-center gap-2 text-xs text-foreground/75">
696 <input
697 type="checkbox"
698 checked={includeAudio}
699 disabled={submitting}
700 onChange={(event) => setIncludeAudio(event.currentTarget.checked)}
701 />
702 Include a clipped audio reference
703 </label>
704 {includeAudio ? (
705 <div className="rounded-lg border border-border p-2.5">
706 <div className="mb-2 flex justify-between text-xs text-muted-foreground">
707 <span>Audio {formatClipTime(audioStart)}</span>
708 <span>to {formatClipTime(audioEnd)}</span>
709 </div>
710 <input
711 aria-label="Audio clip start"
712 type="range"
713 min={0}
714 max={Math.max(duration, 0.01)}
715 step={0.01}
716 value={Math.min(audioStart, Math.max(duration, 0.01))}
717 disabled={duration <= 0 || submitting}
718 onChange={(event) => {
719 const value = Number(event.currentTarget.value);
720 setAudioStart(Math.min(value, Math.max(0, audioEnd - 0.05)));
721 }}
722 className="h-2 w-full cursor-pointer accent-foreground"
723 />
724 <input
725 aria-label="Audio clip end"
726 type="range"
727 min={0}
728 max={Math.max(duration, 0.01)}
729 step={0.01}
730 value={Math.min(audioEnd, Math.max(duration, 0.01))}
731 disabled={duration <= 0 || submitting}
732 onChange={(event) => {
733 const value = Number(event.currentTarget.value);
734 setAudioEnd(Math.max(value, audioStart + 0.05));
735 }}
736 className="h-2 w-full cursor-pointer accent-foreground"
737 />
738 </div>
739 ) : null}
740 </div>
741
742 <div className="space-y-3">
743 <label className="block space-y-1 text-xs text-muted-foreground">
744 <span>Reference type</span>
745 <select
746 aria-label="Reference type"
747 value={referenceType}
748 disabled={submitting}
749 onChange={(event) => setReferenceType(event.target.value as MemoryReferenceType)}
750 className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground"
751 >
752 {REFERENCE_TYPE_OPTIONS.map((option) => (
753 <option key={option.value} value={option.value}>{option.label}</option>
754 ))}
755 </select>
756 </label>
757 <label className="block space-y-1 text-xs text-muted-foreground">
758 <span>Reference label</span>
759 <input
760 aria-label="Reference label"
761 value={referenceLabel}
762 maxLength={80}
763 disabled={submitting}
764 onChange={(event) => setReferenceLabel(event.target.value)}
765 placeholder="角色_A / 雨夜街道"
766 className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground"
767 />
768 </label>
769 <label className="block space-y-1 text-xs text-muted-foreground">
770 <span>Profile</span>
771 <textarea
772 aria-label="Reference profile"
773 value={profileText}
774 rows={6}
775 disabled={submitting}
776 onChange={(event) => setProfileText(event.target.value)}
777 placeholder="Describe identity, scene, appearance, motion, or sound. Leave blank to ask the configured VLM."
778 className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground"
779 />
780 </label>
781 <p className="text-[11px] leading-relaxed text-muted-foreground">
782 The saved profile and Shot {shotId} provenance are visible to the Agent when it recommends Memory Slots.
783 </p>
784 </div>
785 </div>
786
787 <DialogFooter>
788 <Button type="button" variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>
789 Cancel
790 </Button>
791 <Button
792 type="button"
793 disabled={
794 duration <= 0
795 || submitting
796 || (includeAudio && audioEnd <= audioStart)
797 }
798 onClick={async () => {
799 setSubmitting(true);
800 try {
801 await onSave({
802 timestamp_sec: frameTime,
803 reference_type: referenceType,
804 reference_label: referenceLabel.trim(),
805 profile_text: profileText.trim(),
806 include_audio: includeAudio,
807 ...(includeAudio
808 ? { audio_start_sec: audioStart, audio_end_sec: audioEnd }
809 : {}),
810 });
811 onOpenChange(false);
812 } finally {
813 setSubmitting(false);
814 }
815 }}
816 >
817 {submitting ? <LoaderCircle className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
818 Save to Memory Bank
819 </Button>
820 </DialogFooter>
821 </DialogContent>
822 </Dialog>
823 );
824 }
825
825 lines Plain Text