返回 JoyAI-Echo
MemoryReviewCard.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / thread / MemoryReviewCard.tsx
1 import {
2 Check,
3 Film,
4 HelpCircle,
5 LoaderCircle,
6 Plus,
7 RefreshCw,
8 Sparkles,
9 X,
10 } from "lucide-react";
11 import { useEffect, useMemo, useRef, useState } from "react";
12
13 import { ImageLightbox } from "@/components/ImageLightbox";
14 import { CompactAudioPlayer } from "@/components/thread/CompactAudioPlayer";
15 import { ShotReferenceDialog } from "@/components/workplace/ShotMemorySlots";
16 import { Button } from "@/components/ui/button";
17 import {
18 Dialog,
19 DialogContent,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogTitle,
24 } from "@/components/ui/dialog";
25 import { Switch } from "@/components/ui/switch";
26 import {
27 Tooltip,
28 TooltipContent,
29 TooltipProvider,
30 TooltipTrigger,
31 } from "@/components/ui/tooltip";
32 import {
33 memoryDisplayName,
34 type MemoryReview,
35 type MemorySelection,
36 type MemoryWorkspaceAsset,
37 type ShotMemoryAssetCreate,
38 type UIImage,
39 } from "@/lib/types";
40
41 export type NextContinuousControl = {
42 /** Display shot number for a+1 (e.g. 3 when this review is Shot 2). */
43 nextShotId: number;
44 enabled: boolean;
45 /** Locked after the next shot has started/finished generation. */
46 disabled?: boolean;
47 onToggle: (enabled: boolean) => void;
48 };
49
50 interface MemoryReviewCardProps {
51 review: MemoryReview;
52 onApprove?: (retainedMemoryIds: string[]) => void;
53 onReselect?: (memoryId: string) => void;
54 onManualSelect?: (
55 memoryId: string,
56 timestampSec: number,
57 ) => void | Promise<void>;
58 onSelectMode?: (mode: "manual" | "vlm") => void | Promise<void>;
59 memoryAssets?: MemoryWorkspaceAsset[];
60 onCreateMemoryAsset?: (
61 shotId: number,
62 asset: ShotMemoryAssetCreate,
63 ) => Promise<void>;
64 busyAction?:
65 | "approve"
66 | "reselect"
67 | "manual_select"
68 | "select_mode"
69 | null;
70 /** Shot a Memory footer controls Shot a+1 continuous mode. */
71 nextContinuous?: NextContinuousControl | null;
72 }
73
74 function initialRetainedMemoryIds(review: MemoryReview): Set<string> {
75 const available = new Set(
76 review.selections.map((selection) => selection.memory_id),
77 );
78 const defaults =
79 review.retained_memory_ids !== undefined
80 ? review.retained_memory_ids
81 : review.selection_mode === "manual"
82 ? (review.manual_selected_ids ?? [])
83 : review.selections.map((selection) => selection.memory_id);
84 return new Set(defaults.filter((memoryId) => available.has(memoryId)));
85 }
86
87 export function MemoryReviewCard({
88 review,
89 onApprove,
90 onReselect,
91 onManualSelect,
92 onSelectMode,
93 memoryAssets = [],
94 onCreateMemoryAsset,
95 busyAction = null,
96 nextContinuous = null,
97 }: MemoryReviewCardProps) {
98 const canAct =
99 review.status === "awaiting_review" || review.status === "error";
100 const awaitingMethod = review.status === "awaiting_method";
101 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
102 const [manualSelection, setManualSelection] =
103 useState<MemorySelection | null>(null);
104 const [memoryBuilderOpen, setMemoryBuilderOpen] = useState(false);
105 const [referenceDialogOpen, setReferenceDialogOpen] = useState(false);
106 const [retainedIds, setRetainedIds] = useState<Set<string>>(() =>
107 initialRetainedMemoryIds(review),
108 );
109 const selectionIdsKey = review.selections
110 .map((selection) => selection.memory_id)
111 .join("\u0000");
112 const retainedIdsKey = review.retained_memory_ids?.join("\u0000") ?? "";
113 const manualIdsKey = review.manual_selected_ids?.join("\u0000") ?? "";
114
115 useEffect(() => {
116 setRetainedIds(initialRetainedMemoryIds(review));
117 }, [
118 review.review_id,
119 review.attempt,
120 review.selection_mode,
121 selectionIdsKey,
122 retainedIdsKey,
123 manualIdsKey,
124 ]);
125
126 useEffect(() => {
127 setMemoryBuilderOpen(false);
128 setReferenceDialogOpen(false);
129 }, [review.review_id]);
130
131 const createdAssets = useMemo(
132 () =>
133 memoryAssets.filter(
134 (asset) =>
135 asset.source === "local" &&
136 asset.provenance?.shot_id === review.shot_id,
137 ),
138 [memoryAssets, review.shot_id],
139 );
140 const showMemoryBuilder =
141 !awaitingMethod || memoryBuilderOpen || createdAssets.length > 0;
142
143 const previewImages = useMemo<UIImage[]>(
144 () =>
145 [
146 ...review.selections
147 .filter((s) => Boolean(s.image?.url))
148 .map((s) => ({
149 url: s.image.url,
150 name: s.image.name ?? memoryDisplayName(s),
151 })),
152 ...createdAssets
153 .filter((asset) => Boolean(asset.image?.url))
154 .map((asset) => ({
155 url: asset.image!.url,
156 name: asset.image!.name ?? asset.display_name,
157 })),
158 ],
159 [createdAssets, review.selections],
160 );
161
162 const previewIndexByMemoryId = useMemo(() => {
163 const map = new Map<string, number>();
164 let i = 0;
165 for (const s of review.selections) {
166 if (!s.image?.url) continue;
167 map.set(s.memory_id, i);
168 i += 1;
169 }
170 return map;
171 }, [review.selections]);
172 const createdAssetPreviewIndex = useMemo(() => {
173 const map = new Map<string, number>();
174 let index = review.selections.filter((item) => Boolean(item.image?.url)).length;
175 for (const asset of createdAssets) {
176 if (!asset.image?.url) continue;
177 map.set(asset.asset_id, index);
178 index += 1;
179 }
180 return map;
181 }, [createdAssets, review.selections]);
182 const manuallySelectedIds = new Set(review.manual_selected_ids ?? []);
183 const retainedMemoryIds = review.selections
184 .map((selection) => selection.memory_id)
185 .filter((memoryId) => retainedIds.has(memoryId));
186
187 const renderSelection = (selection: MemorySelection) => (
188 <MemorySelectionTile
189 key={`${selection.kind}:${selection.memory_id}`}
190 selection={selection}
191 selectionMode={review.selection_mode ?? null}
192 manuallySelected={manuallySelectedIds.has(selection.memory_id)}
193 retained={retainedIds.has(selection.memory_id)}
194 canToggle={canAct && busyAction === null}
195 canReselect={canAct && busyAction === null && Boolean(onReselect)}
196 canSelectManually={
197 canAct &&
198 busyAction === null &&
199 Boolean(onManualSelect) &&
200 Boolean(review.source_video?.url)
201 }
202 onReselect={() => onReselect?.(selection.memory_id)}
203 onSelectManually={() => setManualSelection(selection)}
204 onToggleRetained={() =>
205 setRetainedIds((current) => {
206 const next = new Set(current);
207 if (next.has(selection.memory_id)) {
208 next.delete(selection.memory_id);
209 } else {
210 next.add(selection.memory_id);
211 }
212 return next;
213 })
214 }
215 onPreview={
216 previewIndexByMemoryId.has(selection.memory_id)
217 ? () =>
218 setLightboxIndex(
219 previewIndexByMemoryId.get(selection.memory_id)!,
220 )
221 : undefined
222 }
223 />
224 );
225
226 return (
227 <section
228 aria-label={`Shot ${review.shot_id} memory review`}
229 className="mt-3 overflow-hidden rounded-2xl border border-border/70 bg-card/80 shadow-sm"
230 >
231 <header className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
232 <div className="flex items-center gap-2.5">
233 <span className="grid size-8 place-items-center rounded-xl bg-foreground/[0.06] text-foreground/60">
234 <Sparkles className="size-4" aria-hidden />
235 </span>
236 <div>
237 <h3 className="font-semibold tracking-tight">
238 Shot {review.shot_id} · Memory Review
239 </h3>
240 <p className="text-xs text-muted-foreground">
241 <span>Pass {review.attempt}</span> · Echo Director ·{" "}
242 {review.candidate_count} candidates
243 </p>
244 </div>
245 </div>
246
247 <StatusPill review={review} />
248 <div className="text-xs text-muted-foreground">
249 {awaitingMethod
250 ? "Choose whether this shot should create any new Memory."
251 : "Keep any references that help the next shot, or continue with none."}
252 </div>
253 </header>
254
255 {awaitingMethod && !showMemoryBuilder ? (
256 <div className="p-4">
257 <div className="rounded-xl border border-border/70 bg-muted/20 p-4">
258 <h4 className="text-sm font-semibold">
259 Create new Memory from this video?
260 </h4>
261 <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">
262 You can add exact frames yourself or let VLM extract several
263 profiled character, scene, and other references. This step is
264 optional.
265 </p>
266 <div className="mt-4 flex flex-wrap gap-2">
267 <Button
268 type="button"
269 disabled={busyAction !== null}
270 onClick={() => setMemoryBuilderOpen(true)}
271 >
272 <Plus className="mr-2 size-4" />
273 Create New Memory
274 </Button>
275 <Button
276 type="button"
277 variant="outline"
278 disabled={busyAction !== null || !onApprove}
279 onClick={() => onApprove?.([])}
280 >
281 Continue Without New Memory
282 </Button>
283 </div>
284 </div>
285 </div>
286 ) : review.selections.length > 0 || createdAssets.length > 0 || awaitingMethod ? (
287 <div className="p-4">
288 <section aria-label="Memory references">
289 <div className="mb-3 flex flex-wrap items-start justify-between gap-2">
290 <div>
291 <h4 className="text-sm font-semibold">New Memory</h4>
292 <p className="text-xs text-muted-foreground">
293 Add a frame and finish its type and profile here, or let VLM
294 prepare a starting set.
295 </p>
296 </div>
297 {awaitingMethod ? (
298 <Button
299 type="button"
300 variant="outline"
301 size="sm"
302 disabled={busyAction !== null || !onSelectMode}
303 onClick={() => onSelectMode?.("vlm")}
304 >
305 <Sparkles className="mr-2 size-3.5" />
306 Extract with VLM
307 </Button>
308 ) : null}
309 </div>
310 <div className="mb-2">
311 <p className="text-xs text-muted-foreground">
312 Keep any number of references, including none. Type labels are
313 descriptive, not requirements.
314 </p>
315 </div>
316 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
317 {review.selections.map(renderSelection)}
318 {createdAssets.map((asset) => (
319 <CreatedMemoryTile
320 key={asset.asset_id}
321 asset={asset}
322 onPreview={
323 createdAssetPreviewIndex.has(asset.asset_id)
324 ? () => setLightboxIndex(createdAssetPreviewIndex.get(asset.asset_id)!)
325 : undefined
326 }
327 />
328 ))}
329 {review.source_video?.url && onCreateMemoryAsset ? (
330 <button
331 type="button"
332 aria-label="Add memory from video"
333 disabled={busyAction !== null}
334 onClick={() => setReferenceDialogOpen(true)}
335 className="flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-muted/15 p-5 text-center text-muted-foreground transition-colors hover:border-foreground/35 hover:bg-muted/30 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
336 >
337 <span className="grid size-11 place-items-center rounded-full border border-border bg-background">
338 <Plus className="size-5" />
339 </span>
340 <span>
341 <strong className="block text-sm text-foreground">Add Memory</strong>
342 <span className="mt-1 block text-xs">Choose a frame, type, and profile</span>
343 </span>
344 </button>
345 ) : null}
346 </div>
347 </section>
348 </div>
349 ) : null}
350
351 {review.error ? (
352 <p
353 role="alert"
354 className="mx-4 mb-3 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"
355 >
356 {review.error}
357 </p>
358 ) : null}
359
360 {canAct || nextContinuous || (awaitingMethod && showMemoryBuilder) ? (
361 <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 bg-background/45 px-4 py-3">
362 {nextContinuous ? (
363 <div className="flex items-center gap-1.5">
364 <span className="text-[12px] text-foreground/70">
365 Shot {nextContinuous.nextShotId} continuous first frame
366 </span>
367 <Switch
368 checked={nextContinuous.enabled}
369 disabled={nextContinuous.disabled}
370 onCheckedChange={nextContinuous.onToggle}
371 aria-label={`Enable continuous first frame for Shot ${nextContinuous.nextShotId}`}
372 />
373 <TooltipProvider delayDuration={200}>
374 <Tooltip>
375 <TooltipTrigger asChild>
376 <button
377 type="button"
378 className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/40 transition-colors hover:text-foreground/60"
379 aria-label="Continuous first frame details"
380 >
381 <HelpCircle className="h-3.5 w-3.5" />
382 </button>
383 </TooltipTrigger>
384 <TooltipContent side="top" className="max-w-[220px] text-xs">
385 Use the previous shot's final frame as the next shot's opening frame.
386 </TooltipContent>
387 </Tooltip>
388 </TooltipProvider>
389 </div>
390 ) : (
391 <span />
392 )}
393 {(canAct || (awaitingMethod && showMemoryBuilder)) && onApprove ? (
394 <div className="ml-auto flex flex-col items-end gap-1">
395 <span className="text-xs text-muted-foreground">
396 {retainedMemoryIds.length + createdAssets.length} reference
397 {retainedMemoryIds.length + createdAssets.length === 1 ? "" : "s"} ready
398 </span>
399 <Button
400 type="button"
401 disabled={busyAction !== null}
402 onClick={() => onApprove(retainedMemoryIds)}
403 >
404 <Check className="mr-2 size-4" />
405 Continue
406 </Button>
407 </div>
408 ) : null}
409 </footer>
410 ) : null}
411
412 <ImageLightbox
413 images={previewImages}
414 index={lightboxIndex}
415 onIndexChange={setLightboxIndex}
416 onOpenChange={(open) => {
417 if (!open) setLightboxIndex(null);
418 }}
419 />
420 <ManualFrameDialog
421 open={manualSelection !== null}
422 sourceVideoUrl={review.source_video?.url ?? ""}
423 selection={manualSelection}
424 onOpenChange={(open) => {
425 if (!open) setManualSelection(null);
426 }}
427 onConfirm={async (timestampSec) => {
428 if (!manualSelection || !onManualSelect) return;
429 await onManualSelect(manualSelection.memory_id, timestampSec);
430 setManualSelection(null);
431 }}
432 />
433 {review.source_video?.url && onCreateMemoryAsset ? (
434 <ShotReferenceDialog
435 open={referenceDialogOpen}
436 shotId={review.shot_id}
437 videoUrl={review.source_video.url}
438 onOpenChange={setReferenceDialogOpen}
439 onSave={(asset) => onCreateMemoryAsset(review.shot_id, asset)}
440 />
441 ) : null}
442 </section>
443 );
444 }
445
446 function StatusPill({ review }: { review: MemoryReview }) {
447 if (review.status === "awaiting_method") {
448 return (
449 <span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-foreground/70">
450 Memory is optional
451 </span>
452 );
453 }
454 if (review.status === "selecting" || review.status === "reselecting") {
455 return (
456 <span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-foreground/70">
457 <LoaderCircle className="size-3.5 animate-spin" aria-hidden />
458 Selecting memories
459 </span>
460 );
461 }
462 if (review.status === "approved") {
463 return (
464 <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
465 <Check className="size-3.5" />
466 Approved and stored
467 </span>
468 );
469 }
470 if (review.status === "error") {
471 return (
472 <span className="rounded-full bg-destructive/10 px-2.5 py-1 text-xs text-destructive">
473 Selection failed · Retry available
474 </span>
475 );
476 }
477 return (
478 <span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
479 Awaiting approval
480 </span>
481 );
482 }
483
484 function CreatedMemoryTile({
485 asset,
486 onPreview,
487 }: {
488 asset: MemoryWorkspaceAsset;
489 onPreview?: () => void;
490 }) {
491 const typeLabel = asset.reference_type
492 ? asset.reference_type.charAt(0).toUpperCase() + asset.reference_type.slice(1)
493 : "Reference";
494 return (
495 <article className="overflow-hidden rounded-xl border border-emerald-500/35 bg-card/80">
496 <div className="relative aspect-video overflow-hidden bg-muted">
497 {asset.image?.url ? (
498 <button
499 type="button"
500 onClick={onPreview}
501 disabled={!onPreview}
502 aria-label={`Preview ${asset.display_name}`}
503 className="group relative block size-full text-left disabled:cursor-default"
504 >
505 <img
506 src={asset.image.url}
507 alt={asset.display_name}
508 className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
509 />
510 </button>
511 ) : (
512 <div className="grid size-full place-items-center text-xs text-muted-foreground">
513 Audio reference
514 </div>
515 )}
516 <span className="pointer-events-none absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-white">
517 {typeLabel}
518 </span>
519 </div>
520 <div className="space-y-2 p-3">
521 <div>
522 <strong className="block truncate text-xs">{asset.display_name}</strong>
523 <span className="text-[11px] text-emerald-700 dark:text-emerald-300">
524 Saved to Memory Bank
525 </span>
526 </div>
527 {asset.profile_text ? (
528 <p className="line-clamp-3 text-xs leading-relaxed text-muted-foreground">
529 {asset.profile_text}
530 </p>
531 ) : (
532 <p className="text-xs text-muted-foreground">Profile unavailable</p>
533 )}
534 {asset.audio?.url ? (
535 <CompactAudioPlayer
536 src={asset.audio.url}
537 label={`${asset.display_name} memory audio`}
538 className="w-full"
539 />
540 ) : null}
541 </div>
542 </article>
543 );
544 }
545
546 function MemorySelectionTile({
547 selection,
548 selectionMode,
549 manuallySelected,
550 retained,
551 canToggle,
552 canReselect,
553 canSelectManually,
554 onReselect,
555 onSelectManually,
556 onToggleRetained,
557 onPreview,
558 }: {
559 selection: MemorySelection;
560 selectionMode: "manual" | "vlm" | null;
561 manuallySelected: boolean;
562 retained: boolean;
563 canToggle: boolean;
564 canReselect: boolean;
565 canSelectManually: boolean;
566 onReselect: () => void;
567 onSelectManually: () => void;
568 onToggleRetained: () => void;
569 onPreview?: () => void;
570 }) {
571 const label = memoryDisplayName(selection);
572 const kindLabel =
573 selection.kind === "character"
574 ? "Character"
575 : selection.kind === "previous_shot"
576 ? "Scene"
577 : "Reference";
578 return (
579 <article
580 className={`overflow-hidden rounded-xl border bg-card/80 transition-opacity ${
581 retained ? "border-border/70" : "border-border/40 opacity-55"
582 }`}
583 >
584 <div className="relative aspect-video overflow-hidden bg-muted">
585 <button
586 type="button"
587 onClick={onPreview}
588 disabled={!onPreview}
589 aria-label={`Preview ${label} memory frame`}
590 className="group relative block size-full text-left disabled:cursor-default"
591 >
592 <img
593 src={selection.image.url}
594 alt={`Selected ${label} memory frame`}
595 className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02] group-disabled:group-hover:scale-100"
596 />
597 <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-8 text-white">
598 <strong className="min-w-0 truncate text-xs tracking-wide">
599 {label}
600 </strong>
601 </div>
602 </button>
603 <span className="pointer-events-none absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-white">
604 {kindLabel}
605 </span>
606 </div>
607 <div className="space-y-2.5 p-3">
608 {selectionMode === "manual" ? (
609 <p className="text-[11px] font-medium text-foreground/60">
610 {!retained
611 ? "Not included"
612 : manuallySelected
613 ? "Frame selected"
614 : "Using the current candidate"}
615 </p>
616 ) : null}
617 <p className="line-clamp-3 text-xs leading-relaxed text-muted-foreground">
618 {selection.reasoning}
619 </p>
620 {selection.audio?.url ? (
621 <CompactAudioPlayer
622 src={selection.audio.url}
623 label={`${label} memory audio`}
624 className="w-full"
625 />
626 ) : (
627 <p className="text-[11px] text-muted-foreground">No reference audio</p>
628 )}
629 <Button
630 type="button"
631 variant="outline"
632 size="sm"
633 className="w-full"
634 disabled={!canToggle}
635 aria-pressed={retained}
636 onClick={onToggleRetained}
637 >
638 {retained ? (
639 <X className="mr-2 size-3.5" />
640 ) : (
641 <Check className="mr-2 size-3.5" />
642 )}
643 {retained ? "Remove from Memory" : "Keep in Memory"}
644 </Button>
645 <Button
646 type="button"
647 variant="outline"
648 size="sm"
649 className="w-full"
650 disabled={!canReselect}
651 onClick={onReselect}
652 >
653 <RefreshCw className="mr-2 size-3.5" />
654 {selectionMode === "manual"
655 ? "Ask VLM for This Memory"
656 : "Reselect This Memory"}
657 </Button>
658 {canSelectManually ? (
659 <Button
660 type="button"
661 variant="outline"
662 size="sm"
663 className="w-full"
664 onClick={onSelectManually}
665 >
666 <Film className="mr-2 size-3.5" />
667 Choose from Video
668 </Button>
669 ) : null}
670 </div>
671 </article>
672 );
673 }
674
675 function formatTime(seconds: number): string {
676 const safe = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
677 const minutes = Math.floor(safe / 60);
678 const remainder = safe - minutes * 60;
679 return `${minutes}:${remainder.toFixed(2).padStart(5, "0")}`;
680 }
681
682 function ManualFrameDialog({
683 open,
684 sourceVideoUrl,
685 selection,
686 onOpenChange,
687 onConfirm,
688 }: {
689 open: boolean;
690 sourceVideoUrl: string;
691 selection: MemorySelection | null;
692 onOpenChange: (open: boolean) => void;
693 onConfirm: (timestampSec: number) => void | Promise<void>;
694 }) {
695 const videoRef = useRef<HTMLVideoElement>(null);
696 const [duration, setDuration] = useState(0);
697 const [timestampSec, setTimestampSec] = useState(0);
698 const [submitting, setSubmitting] = useState(false);
699
700 useEffect(() => {
701 if (!open || !selection) return;
702 setDuration(0);
703 setTimestampSec(Math.max(0, selection.timestamp_sec));
704 setSubmitting(false);
705 }, [open, selection]);
706
707 const seek = (next: number) => {
708 const value = Math.min(Math.max(next, 0), duration || next);
709 setTimestampSec(value);
710 if (videoRef.current) {
711 videoRef.current.currentTime = value;
712 videoRef.current.pause();
713 }
714 };
715
716 const label = selection ? memoryDisplayName(selection) : "Memory";
717 return (
718 <Dialog open={open} onOpenChange={onOpenChange}>
719 <DialogContent className="max-w-3xl">
720 <DialogHeader>
721 <DialogTitle>Choose Memory Frame</DialogTitle>
722 <DialogDescription>
723 Drag through Shot {selection?.source_shot_id ?? ""} and choose the
724 frame to use for {label}. Its reference audio will stay unchanged.
725 </DialogDescription>
726 </DialogHeader>
727
728 <div className="overflow-hidden rounded-lg border border-border bg-black">
729 {sourceVideoUrl ? (
730 <video
731 ref={videoRef}
732 src={sourceVideoUrl}
733 controls
734 playsInline
735 preload="metadata"
736 className="aspect-video w-full object-contain"
737 onLoadedMetadata={(event) => {
738 const nextDuration = Number(event.currentTarget.duration || 0);
739 setDuration(nextDuration);
740 const initial = Math.min(
741 Math.max(selection?.timestamp_sec ?? 0, 0),
742 nextDuration || Number.POSITIVE_INFINITY,
743 );
744 event.currentTarget.currentTime = initial;
745 setTimestampSec(initial);
746 }}
747 onTimeUpdate={(event) =>
748 setTimestampSec(event.currentTarget.currentTime)
749 }
750 onSeeked={(event) =>
751 setTimestampSec(event.currentTarget.currentTime)
752 }
753 />
754 ) : null}
755 </div>
756
757 <div className="space-y-2">
758 <input
759 aria-label="Memory frame time"
760 type="range"
761 min={0}
762 max={Math.max(duration, 0.01)}
763 step={0.01}
764 value={Math.min(timestampSec, Math.max(duration, 0.01))}
765 disabled={duration <= 0 || submitting}
766 onChange={(event) => seek(Number(event.currentTarget.value))}
767 className="h-2 w-full cursor-pointer accent-foreground disabled:cursor-not-allowed"
768 />
769 <div className="flex justify-between text-xs tabular-nums text-muted-foreground">
770 <span>Selected {formatTime(timestampSec)}</span>
771 <span>{formatTime(duration)}</span>
772 </div>
773 </div>
774
775 <DialogFooter>
776 <Button
777 type="button"
778 variant="outline"
779 disabled={submitting}
780 onClick={() => onOpenChange(false)}
781 >
782 Cancel
783 </Button>
784 <Button
785 type="button"
786 disabled={duration <= 0 || submitting}
787 onClick={async () => {
788 setSubmitting(true);
789 try {
790 await onConfirm(timestampSec);
791 } finally {
792 setSubmitting(false);
793 }
794 }}
795 >
796 {submitting ? (
797 <LoaderCircle className="mr-2 size-4 animate-spin" />
798 ) : (
799 <Check className="mr-2 size-4" />
800 )}
801 Use This Frame
802 </Button>
803 </DialogFooter>
804 </DialogContent>
805 </Dialog>
806 );
807 }
808
808 lines Plain Text