返回 JoyAI-Echo
FramesPanel.tsx
1 import {
2 useCallback,
3 useEffect,
4 useLayoutEffect,
5 useMemo,
6 useRef,
7 useState,
8 } from "react";
9 import type { ReactNode } from "react";
10 import {
11 AlertCircle,
12 ArrowRight,
13 Check,
14 Circle,
15 Clapperboard,
16 Film,
17 HelpCircle,
18 Loader2,
19 Minus,
20 Play,
21 Plus,
22 RefreshCcw,
23 Send,
24 Sparkles,
25 X,
26 ThumbsUp,
27 SquarePen,
28 ChevronDown,
29 FileText,
30 Copy,
31 // Pencil,
32 } from "lucide-react";
33
34 import { Button } from "@/components/ui/button";
35 import { Switch } from "@/components/ui/switch";
36 import {
37 Tooltip,
38 TooltipContent,
39 TooltipProvider,
40 TooltipTrigger,
41 } from "@/components/ui/tooltip";
42 import { ImageLightbox } from "@/components/ImageLightbox";
43 import { ShotVideoPlayer } from "@/components/longvideo/ShotVideoPlayer";
44 import {
45 Sheet,
46 SheetContent,
47 SheetHeader,
48 SheetTitle,
49 } from "@/components/ui/sheet";
50 import {
51 memoryDisplayName,
52 type GenerationMemory,
53 type UIImage,
54 } from "@/lib/types";
55 import { cn } from "@/lib/utils";
56
57 /** Shot1 first-frame display (uploaded from Composer; read-only here). */
58 export type Shot1FirstFrameControls = {
59 displayUrl: string | null;
60 videoSize: { width: number; height: number };
61 };
62
63 // export type FrameStatus = "idle" | "generating" | "done" | "error";
64 export type FrameStatus =
65 | "planned"
66 | "prompt_ready"
67 | "queued"
68 | "generated"
69 | "error"
70 | "review_pass"
71 | "review_fail"
72 | "approved"
73 | "revised_prompt_ready";
74
75 export interface Frame {
76 id: string;
77 shotId?: number;
78 cut?: boolean;
79 caption?: string;
80 numFrames?: number;
81 segmentText: string;
82 prompt: string;
83 status: FrameStatus;
84 videoUrl?: string;
85 error?: string;
86 durationSec?: number;
87 referenceShotIds?: number[];
88 referenceNote?: string;
89 canGenerate?: boolean;
90 dependencyMessage?: string;
91 hintMessage?: string;
92 hasActions?: boolean;
93 reviewNotes?: string;
94 accepted?: boolean;
95 generationMemories?: GenerationMemory[];
96 continuousEnabled?: boolean;
97 }
98
99 interface FramesPanelProps {
100 frames: Frame[];
101 memoryBank?: ReactNode;
102 renderMemorySlots?: (frameId: string) => ReactNode;
103 batchGenerating?: boolean;
104 composeDisabled?: boolean;
105 referencesReady?: boolean;
106 onGenerate: (frameId: string) => void;
107 onGenerateAll: () => void;
108 onUpdatePrompt: (frameId: string, prompt: string) => void;
109 onUpdateDuration: (frameId: string, durationSec: number) => void;
110 onRetry: (frameId: string) => void;
111 onAccept: (frameId: string) => void;
112 onAcceptAll: () => void;
113 acceptAllBusy?: boolean;
114 onRevise: (frameId: string, feedback: string) => void | Promise<void>;
115 onSetContinuousMode?: (frameId: string, enabled: boolean) => void;
116 busyFrameId?: string | null;
117 onCompose: () => void;
118 /** Notifies when a shot prompt textarea enters/leaves edit mode (focus session). */
119 onPromptEditingChange?: (editing: boolean) => void;
120 /** Shot1 first-frame reference image (display only). */
121 shot1FirstFrame?: Shot1FirstFrameControls | null;
122 }
123
124 const injectCSS = `
125 @keyframes shot-shimmer {
126 0% { background-position: 200% 0; }
127 100% { background-position: -200% 0; }
128 }
129 @keyframes shot-pulse-ring {
130 0%, 100% { opacity: 0.25; transform: scale(1); }
131 50% { opacity: 0.5; transform: scale(1.18); }
132 }
133 @keyframes shot-scan-line {
134 0% { top: -2px; }
135 100% { top: calc(100% + 2px); }
136 }
137 `;
138
139 const isReadyForMerge = (status: FrameStatus) =>
140 status === "generated" || status === "review_pass" || status === "approved";
141
142 const isApproved = (status: FrameStatus) => status === "approved";
143
144 const isDone = (status: FrameStatus) => isReadyForMerge(status);
145 const isActive = (status: FrameStatus) =>
146 status === "queued" ||
147 status === "review_fail" ||
148 status === "revised_prompt_ready";
149 const isError = (status: FrameStatus) => status === "error";
150 const isIdle = (status: FrameStatus) =>
151 status === "planned" || status === "prompt_ready";
152 /** Shot 已开始/完成生成后锁定首尾衔接,不可再改。 */
153 const isContinuousLocked = (status: FrameStatus) => !isIdle(status);
154
155 export function FramesPanel({
156 frames,
157 memoryBank,
158 renderMemorySlots,
159 batchGenerating = false,
160 composeDisabled = false,
161 referencesReady = true,
162 onGenerate,
163 onGenerateAll,
164 onUpdatePrompt,
165 onUpdateDuration,
166 onRetry,
167 onAccept,
168 onAcceptAll,
169 acceptAllBusy = false,
170 onRevise,
171 onSetContinuousMode,
172 busyFrameId = null,
173 onCompose,
174 onPromptEditingChange,
175 shot1FirstFrame = null,
176 }: FramesPanelProps) {
177 const doneCount = frames.filter((f) => isReadyForMerge(f.status)).length;
178 const errorCount = frames.filter((f) => isError(f.status)).length;
179 const generatingCount = frames.filter((f) => isActive(f.status)).length;
180 // 所有分镜都生成完成,并且有视频 url
181 const allDone =
182 frames.length > 0 &&
183 // frames.every((f) => isReadyForMerge(f.status) && f.videoUrl);
184 frames.every((f) => isReadyForMerge(f.status));
185 const hasUnacceptedShots = frames.some(
186 // (f) => f.hasActions && isReadyForMerge(f.status) && f.status !== "approved",
187 (f) => isReadyForMerge(f.status) && f.status !== "approved",
188 );
189 const canCompose = allDone && !hasUnacceptedShots;
190 const anyGenerating = generatingCount > 0;
191 const hasGeneratableFrame = frames.some(
192 (frame) => isIdle(frame.status) && frame.canGenerate !== false,
193 );
194 const hasContent = frames.length > 0;
195 const progressPct = hasContent
196 ? Math.round((doneCount / frames.length) * 100)
197 : 0;
198
199 if (!hasContent || !referencesReady) {
200 return (
201 <div className="flex h-full min-h-0 flex-col">
202 {memoryBank ? <div className="shrink-0">{memoryBank}</div> : null}
203 <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
204 <div className="relative mb-6">
205 <div className="absolute -inset-4 rounded-2xl bg-foreground/[0.02]" />
206 <div className="relative grid h-14 w-14 place-items-center rounded-2xl bg-foreground/[0.04] ring-1 ring-inset ring-foreground/[0.06]">
207 <Clapperboard className="h-6 w-6 text-foreground/40" />
208 </div>
209 </div>
210 <p className="text-[15px] font-semibold text-foreground/90">
211 Developing shots...
212 </p>
213 <p className="mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">
214 The director is shaping each shot for production.
215 </p>
216 </div>
217 </div>
218 );
219 }
220
221 // if (!referencesReady) {
222 // return (
223 // <div className="flex h-full flex-col items-center justify-center px-8 text-center">
224 // <Loader2 className="mb-4 h-8 w-8 animate-spin text-foreground/35" />
225 // <p className="text-[15px] font-semibold text-foreground/90">
226 // 正在规划参考镜头
227 // </p>
228 // <p className="mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">
229 // Agent 正在为每个分镜选择参考镜头,完成后即可手动生成。
230 // </p>
231 // </div>
232 // );
233 // }
234
235 return (
236 <div className="flex h-full flex-col">
237 <style>{injectCSS}</style>
238
239 {/* ── status bar ── */}
240 <div className="shrink-0 border-b border-border/40">
241 <div className="flex items-center justify-between px-4 py-2.5">
242 <div className="flex items-center gap-3">
243 <span className="text-[12px] font-medium tabular-nums text-foreground/70">
244 {frames.filter((f) => isApproved(f.status)).length}/
245 {frames.length}
246 <span className="ml-1 font-normal text-muted-foreground">
247 Approved
248 </span>
249 </span>
250 {/* <span className="text-[12px] font-medium tabular-nums text-foreground/70">
251 {doneCount}/{frames.length}
252 <span className="ml-1 font-normal text-muted-foreground">
253 Complete
254 </span>
255 </span> */}
256 {anyGenerating && (
257 <span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
258 <Loader2 className="h-3 w-3 animate-spin" />
259 {generatingCount} generating
260 </span>
261 )}
262 {errorCount > 0 && !anyGenerating && (
263 <span className="flex items-center gap-1 text-[11px] text-foreground/40">
264 <AlertCircle className="h-3 w-3" />
265 {errorCount} failed
266 </span>
267 )}
268 </div>
269 {/* {!allDone && (
270 <Button
271 size="sm"
272 onClick={onGenerateAll}
273 disabled={batchGenerating || !hasGeneratableFrame}
274 className={cn(
275 "h-7 gap-1.5 rounded-lg px-3 text-[11px] font-medium",
276 "bg-foreground text-background hover:bg-foreground/90",
277 )}
278 >
279 {batchGenerating ? (
280 <Loader2 className="h-3 w-3 animate-spin" />
281 ) : (
282 <Sparkles className="h-3 w-3" />
283 )}
284 Generate All
285 </Button>
286 )} */}
287 {allDone && hasUnacceptedShots && (
288 <Button
289 size="sm"
290 onClick={onAcceptAll}
291 disabled={acceptAllBusy}
292 className={cn(
293 "h-7 gap-1.5 rounded-lg px-3 text-[11px] font-medium",
294 "bg-foreground text-background hover:bg-foreground/90",
295 )}
296 >
297 {acceptAllBusy ? (
298 <Loader2 className="h-3 w-3 animate-spin" />
299 ) : (
300 <Check className="h-3 w-3" />
301 )}
302 Approve All Shots
303 </Button>
304 )}
305 </div>
306
307 {(anyGenerating || (doneCount > 0 && !allDone)) && (
308 <div className="px-4 pb-2.5">
309 <div className="h-[3px] overflow-hidden rounded-full bg-foreground/[0.06]">
310 <div
311 className="h-full rounded-full bg-foreground/20 transition-all duration-700 ease-out"
312 style={{ width: `${progressPct}%` }}
313 />
314 </div>
315 </div>
316 )}
317 </div>
318
319 {/* ── timeline ── */}
320 <div className="min-h-0 flex-1 overflow-y-auto py-4 pr-4 pl-0 scrollbar-thin">
321 {memoryBank ? (
322 <div className="sticky top-0 z-30 mb-3 bg-background/95 pb-1 backdrop-blur-sm">
323 {memoryBank}
324 </div>
325 ) : null}
326 <div className="relative">
327 {/* continuous vertical line behind all badges */}
328 <div
329 className="absolute left-7 top-0 bottom-0 w-px bg-border/40"
330 style={{ zIndex: 0 }}
331 />
332
333 {
334 frames.reduce<{ startSec: number; elements: React.ReactNode[] }>(
335 (acc, frame, idx) => {
336 const dur = frame.durationSec ?? 5;
337 acc.elements.push(
338 <div key={frame.id} className="relative flex">
339 {/* ── left rail: badge ── */}
340 <div className="relative z-10 flex w-14 shrink-0 justify-center pt-3">
341 <div
342 className={cn(
343 "grid h-7 w-7 place-items-center rounded-full text-[11px] font-semibold tabular-nums transition-all duration-500",
344 "bg-background ring-1",
345 isDone(frame.status) &&
346 "text-foreground/70 ring-foreground/20",
347 isActive(frame.status) &&
348 "text-foreground/50 ring-foreground/15",
349 isError(frame.status) &&
350 "text-foreground/40 ring-foreground/25",
351 isIdle(frame.status) &&
352 "text-muted-foreground/40 ring-border/60",
353 frame.accepted === true && "bg-foreground/10",
354 )}
355 >
356 {isDone(frame.status) ? (
357 <Check className="h-3 w-3" strokeWidth={2.5} />
358 ) : isActive(frame.status) ? (
359 <Loader2 className="h-3 w-3 animate-spin" />
360 ) : isError(frame.status) ? (
361 <X className="h-3 w-3" strokeWidth={2.5} />
362 ) : (
363 idx + 1
364 )}
365 </div>
366 </div>
367
368 {/* ── card ── */}
369 <div className="min-w-0 flex-1 pb-4">
370 <ShotCard
371 frame={frame}
372 index={idx}
373 memorySlots={renderMemorySlots?.(frame.id)}
374 startSec={acc.startSec}
375 durationSec={dur}
376 disableGenerate={batchGenerating}
377 onGenerate={() => onGenerate(frame.id)}
378 onUpdatePrompt={(p) => onUpdatePrompt(frame.id, p)}
379 onUpdateDuration={(d) => onUpdateDuration(frame.id, d)}
380 onRetry={() => onRetry(frame.id)}
381 onAccept={() => onAccept(frame.id)}
382 onRevise={(feedback) => onRevise(frame.id, feedback)}
383 onSetContinuousMode={
384 onSetContinuousMode
385 ? (enabled) =>
386 onSetContinuousMode(frame.id, enabled)
387 : undefined
388 }
389 nextContinuous={
390 idx + 1 < frames.length && onSetContinuousMode
391 ? {
392 enabled:
393 frames[idx + 1].continuousEnabled ?? false,
394 disabled: isContinuousLocked(
395 frames[idx + 1].status,
396 ),
397 onToggle: (enabled: boolean) =>
398 onSetContinuousMode(
399 frames[idx + 1].id,
400 enabled,
401 ),
402 }
403 : null
404 }
405 reviewBusy={busyFrameId === frame.id}
406 onPromptEditingChange={onPromptEditingChange}
407 firstFrame={
408 !renderMemorySlots && idx === 0 && shot1FirstFrame
409 ? shot1FirstFrame
410 : null
411 }
412 />
413 </div>
414 </div>,
415 );
416 acc.startSec += dur;
417 return acc;
418 },
419 { startSec: 0, elements: [] },
420 ).elements
421 }
422 </div>
423 </div>
424
425 {/* ── bottom: 下一步 (consistent with step 1 & 2) ── */}
426 <div className="shrink-0 border-t border-border/50 px-4 py-3">
427 <Button
428 onClick={onCompose}
429 disabled={!canCompose || composeDisabled}
430 className={cn(
431 "w-full rounded-lg text-[13px] font-medium",
432 "bg-foreground text-background",
433 "hover:bg-foreground/90 active:scale-[0.99]",
434 "h-9 transition-all duration-200",
435 "disabled:opacity-40",
436 )}
437 >
438 Next Step
439 <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
440 </Button>
441 </div>
442 </div>
443 );
444 }
445
446 /* ─────────────────────────────────────────── */
447
448 function ShotCard({
449 frame,
450 index,
451 startSec = 0,
452 durationSec = 5,
453 disableGenerate = false,
454 onGenerate,
455 onUpdatePrompt,
456 onUpdateDuration,
457 onRetry,
458 onAccept,
459 onRevise,
460 onSetContinuousMode,
461 // nextContinuous,
462 reviewBusy = false,
463 onPromptEditingChange,
464 firstFrame = null,
465 memorySlots,
466 }: {
467 frame: Frame;
468 index: number;
469 startSec?: number;
470 durationSec?: number;
471 disableGenerate?: boolean;
472 onGenerate: () => void;
473 onUpdatePrompt: (prompt: string) => void;
474 onUpdateDuration: (durationSec: number) => void;
475 onRetry: () => void;
476 onAccept: () => void;
477 onRevise: (feedback: string) => void | Promise<void>;
478 onSetContinuousMode?: (enabled: boolean) => void;
479 /** Next shot's continuous mode toggle data, for display in the memory box. */
480 nextContinuous?: {
481 enabled: boolean;
482 disabled?: boolean;
483 onToggle: (enabled: boolean) => void;
484 } | null;
485 reviewBusy?: boolean;
486 onPromptEditingChange?: (editing: boolean) => void;
487 firstFrame?: Shot1FirstFrameControls | null;
488 memorySlots?: ReactNode;
489 }) {
490 const [editing, setEditing] = useState(false);
491 const [draft, setDraft] = useState(frame.prompt || frame.segmentText);
492 const [dirty, setDirty] = useState(false);
493 const [durInput, setDurInput] = useState(String(durationSec));
494 const [revising, setRevising] = useState(false);
495 const [reviseFeedback, setReviseFeedback] = useState("");
496 const [revisionSubmitted, setRevisionSubmitted] = useState(false);
497 const [localError, setLocalError] = useState<string | null>(null);
498 const taRef = useRef<HTMLTextAreaElement>(null);
499 const reviseRef = useRef<HTMLTextAreaElement>(null);
500
501 const [textOpen, setTextOpen] = useState(false);
502 const [isTruncated, setIsTruncated] = useState(false);
503 const [detailOpen, setDetailOpen] = useState(false);
504 const [captionCopied, setCaptionCopied] = useState(false);
505 const [memoryLightboxIndex, setMemoryLightboxIndex] = useState<number | null>(
506 null,
507 );
508 const clampedMeasureRef = useRef<HTMLParagraphElement>(null);
509 const fullMeasureRef = useRef<HTMLParagraphElement>(null);
510
511 const firstFrameDisplayUrl = firstFrame?.displayUrl || null;
512 const [firstFrameLightboxOpen, setFirstFrameLightboxOpen] = useState(false);
513
514 const segmentText = frame.segmentText || frame.prompt;
515
516 const memoryPreviewImages = useMemo<UIImage[]>(
517 () =>
518 (frame.generationMemories ?? [])
519 .filter((memory) => Boolean(memory.image?.url))
520 .map((memory) => ({
521 url: memory.image.url,
522 name: memory.image.name ?? memoryDisplayName(memory),
523 })),
524 [frame.generationMemories],
525 );
526
527 const memoryPreviewIndexById = useMemo(() => {
528 const map = new Map<string, number>();
529 let i = 0;
530 for (const memory of frame.generationMemories ?? []) {
531 if (!memory.image?.url) continue;
532 map.set(memory.id, i);
533 i += 1;
534 }
535 return map;
536 }, [frame.generationMemories]);
537
538 useLayoutEffect(() => {
539 if (editing) return;
540
541 const clamped = clampedMeasureRef.current;
542 const full = fullMeasureRef.current;
543 if (!clamped || !full) return;
544
545 const check = () => {
546 const truncated = full.scrollHeight > clamped.clientHeight;
547 setIsTruncated(truncated);
548 if (!truncated) setTextOpen(false);
549 };
550
551 check();
552 const ro = new ResizeObserver(check);
553 ro.observe(clamped);
554 ro.observe(full);
555 return () => ro.disconnect();
556 }, [segmentText, editing, textOpen]);
557
558 const canReviewShot =
559 frame.status === "generated" || frame.status === "review_pass";
560 const displayedVideoUrl = frame.videoUrl;
561 const serverRevisionPending =
562 frame.status === "review_fail" || frame.status === "queued";
563 const revisionLocked = revisionSubmitted || serverRevisionPending;
564 const showActions =
565 // frame.hasActions === true && canReviewShot && !revisionLocked;
566 canReviewShot && !revisionLocked;
567
568 useEffect(() => {
569 if (!editing) setDraft(frame.prompt || frame.segmentText);
570 }, [frame.prompt, frame.segmentText, editing]);
571 useEffect(() => setDurInput(String(durationSec)), [durationSec]);
572 useEffect(() => {
573 if (editing && taRef.current) {
574 const el = taRef.current;
575 el.focus();
576 el.style.height = "auto";
577 el.style.height = `${el.scrollHeight}px`;
578 }
579 }, [editing]);
580
581 useEffect(() => {
582 onPromptEditingChange?.(editing);
583 return () => {
584 if (editing) onPromptEditingChange?.(false);
585 };
586 }, [editing, onPromptEditingChange]);
587
588 useEffect(() => {
589 if (revisionSubmitted && !canReviewShot) {
590 setRevisionSubmitted(false);
591 }
592 }, [canReviewShot, revisionSubmitted]);
593
594 useEffect(() => {
595 if (revisionLocked) {
596 setRevising(false);
597 }
598 }, [revisionLocked]);
599
600 useEffect(() => {
601 if (revising && reviseRef.current) {
602 reviseRef.current.focus();
603 }
604 }, [revising]);
605
606 const submitRevise = useCallback(async () => {
607 const trimmed = reviseFeedback.trim();
608 if (!trimmed) {
609 setLocalError("Describe the requested changes first.");
610 return;
611 }
612 setLocalError(null);
613 setRevisionSubmitted(true);
614 setRevising(false);
615 try {
616 await onRevise(trimmed);
617 setReviseFeedback("");
618 } catch (error) {
619 setRevisionSubmitted(false);
620 setRevising(true);
621 setLocalError(
622 error instanceof Error ? error.message : "Revision could not be submitted.",
623 );
624 }
625 }, [onRevise, reviseFeedback]);
626
627 const save = useCallback(() => {
628 const trimmed = draft.trim();
629 onUpdatePrompt(trimmed);
630 setEditing(false);
631 if (trimmed !== frame.prompt && isDone(frame.status)) setDirty(true);
632 }, [draft, frame.prompt, frame.status, onUpdatePrompt]);
633
634 const cancel = useCallback(() => {
635 setDraft(frame.prompt || frame.segmentText);
636 setEditing(false);
637 }, [frame.prompt, frame.segmentText]);
638
639 useEffect(() => {
640 if (!editing) return;
641 const onKey = (e: KeyboardEvent) => {
642 if (document.activeElement !== taRef.current) return;
643 if (e.isComposing || e.keyCode === 229) return;
644 if (e.key === "Escape") {
645 e.preventDefault();
646 e.stopPropagation();
647 cancel();
648 return;
649 }
650 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
651 e.preventDefault();
652 save();
653 }
654 };
655 window.addEventListener("keydown", onKey, true);
656 return () => window.removeEventListener("keydown", onKey, true);
657 }, [editing, cancel, save]);
658
659 const commitDuration = useCallback(
660 (raw: string) => {
661 const n = Math.max(
662 1,
663 Math.min(10, Math.round(Number(raw) || durationSec)),
664 );
665 setDurInput(String(n));
666 if (n !== durationSec) onUpdateDuration(n);
667 },
668 [durationSec, onUpdateDuration],
669 );
670
671 const showRegen = dirty && isDone(frame.status);
672
673 const fmtTime = (s: number) => {
674 const m = Math.floor(s / 60);
675 const sec = Math.floor(s % 60);
676 return `${m}:${sec.toString().padStart(2, "0")}`;
677 };
678 const endSec = startSec + durationSec;
679 const timeLabel = `${fmtTime(startSec)} – ${fmtTime(endSec)}`;
680
681 return (
682 <div
683 role={revising ? "group" : undefined}
684 aria-label={revising ? `Revise Shot ${index + 1}` : undefined}
685 >
686 {memorySlots}
687 <article
688 className={cn(
689 "group/shot overflow-hidden rounded-2xl transition-all duration-300",
690 "bg-gradient-to-b from-card/80 to-card/40",
691 "border border-border/40",
692 "shadow-[0_1px_3px_0_rgba(0,0,0,0.03)]",
693 isActive(frame.status) &&
694 "border-foreground/12 shadow-[0_2px_12px_0_rgba(0,0,0,0.06)]",
695 isDone(frame.status) && "border-foreground/8",
696 isError(frame.status) && "border-foreground/15",
697 )}
698 >
699 {/* ── header ── */}
700 <div className="flex items-center gap-2.5 px-3.5 py-2.5">
701 <span className="text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/45">
702 Shot {index + 1}
703 </span>
704 <span className="rounded bg-foreground/[0.04] px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground/40">
705 {timeLabel}
706 </span>
707 {isIdle(frame.status) || revising ? (
708 <div className="flex items-center gap-px rounded-md bg-foreground/[0.04]">
709 <button
710 type="button"
711 disabled={durationSec <= 1}
712 onClick={() => commitDuration(String(durationSec - 1))}
713 className={cn(
714 "grid h-5 w-5 place-items-center rounded-l-md transition-colors",
715 "text-muted-foreground/40 hover:bg-foreground/[0.06] hover:text-foreground/60",
716 "disabled:opacity-25 disabled:pointer-events-none",
717 )}
718 >
719 <Minus className="h-2.5 w-2.5" strokeWidth={2.5} />
720 </button>
721 <div className="flex items-baseline gap-[1px] px-1">
722 <input
723 type="text"
724 inputMode="numeric"
725 value={durInput}
726 onChange={(e) =>
727 setDurInput(e.target.value.replace(/[^\d]/g, ""))
728 }
729 onBlur={(e) => commitDuration(e.target.value)}
730 onKeyDown={(e) => {
731 if (e.key === "Enter")
732 commitDuration((e.target as HTMLInputElement).value);
733 }}
734 className={cn(
735 "w-[2ch] bg-transparent text-center text-[9px] font-medium tabular-nums text-muted-foreground/55",
736 "focus:outline-none focus:text-foreground/70",
737 )}
738 />
739 <span className="text-[8px] text-muted-foreground/30">s</span>
740 </div>
741 <button
742 type="button"
743 disabled={durationSec >= 10}
744 onClick={() => commitDuration(String(durationSec + 1))}
745 className={cn(
746 "grid h-5 w-5 place-items-center rounded-r-md transition-colors",
747 "text-muted-foreground/40 hover:bg-foreground/[0.06] hover:text-foreground/60",
748 "disabled:opacity-25 disabled:pointer-events-none",
749 )}
750 >
751 <Plus className="h-2.5 w-2.5" strokeWidth={2.5} />
752 </button>
753 </div>
754 ) : (
755 <div className="flex h-5 items-center gap-px rounded-md bg-foreground/[0.04]">
756 <div className="flex items-baseline gap-[1px] px-1">
757 <input
758 type="text"
759 inputMode="numeric"
760 value={durInput}
761 readOnly={true}
762 disabled={true}
763 className={cn(
764 "w-[2ch] bg-transparent text-center text-[9px] font-medium tabular-nums text-muted-foreground/55",
765 "focus:outline-none focus:text-foreground/70",
766 )}
767 />
768 <span className="text-[8px] text-muted-foreground/30">s</span>
769 </div>
770 </div>
771 )}
772 <div className="ml-auto flex items-center gap-2">
773 {frame.caption && frame.caption.trim() && (
774 <button
775 type="button"
776 onClick={() => setDetailOpen(true)}
777 title="View generation request"
778 className={cn(
779 "inline-flex items-center gap-1 rounded-full px-2 py-0.5",
780 "border border-foreground/10 bg-foreground/[0.03]",
781 "text-[10px] font-medium text-muted-foreground/55",
782 "transition-all hover:bg-foreground/[0.06] hover:text-foreground/70",
783 )}
784 >
785 <FileText className="h-2.5 w-2.5" />
786 Request
787 </button>
788 )}
789 {frame.status === "approved" && (
790 <span className="rounded-full bg-foreground/[0.06] px-2.5 py-0.5 text-[10px] font-medium text-foreground/55">
791 Approved
792 </span>
793 )}
794 {isDone(frame.status) && !dirty && (
795 <span className="rounded-full bg-foreground/[0.06] px-2.5 py-0.5 text-[10px] font-medium text-foreground/55">
796 Complete
797 </span>
798 )}
799 {isActive(frame.status) && !disableGenerate && (
800 <span className="inline-flex items-center gap-1 rounded-full bg-foreground/[0.05] px-2.5 py-0.5 text-[10px] font-medium text-foreground/45">
801 <Loader2 className="h-2.5 w-2.5 animate-spin" />
802 Generating
803 </span>
804 )}
805 {isActive(frame.status) && disableGenerate && (
806 <span className="rounded-full bg-foreground/[0.05] px-2.5 py-0.5 text-[10px] font-medium text-foreground/40">
807 Generating
808 </span>
809 )}
810 {isError(frame.status) && (
811 <button
812 type="button"
813 onClick={onRetry}
814 className={cn(
815 "inline-flex items-center gap-1 rounded-full px-2.5 py-0.5",
816 "border border-foreground/10 bg-foreground/[0.03]",
817 "text-[10px] font-medium text-foreground/50",
818 "transition-all hover:bg-foreground/[0.06] hover:text-foreground/70",
819 )}
820 >
821 <RefreshCcw className="h-2.5 w-2.5" />
822 Retry
823 </button>
824 )}
825 {isIdle(frame.status) && (
826 <button
827 type="button"
828 onClick={onGenerate}
829 disabled={frame.canGenerate === false || disableGenerate}
830 className={cn(
831 "inline-flex items-center gap-1.5 rounded-full px-3 py-1",
832 "border border-border/50 bg-background/80",
833 "text-[11px] font-medium text-muted-foreground/70",
834 "transition-all hover:border-foreground/15 hover:text-foreground/70",
835 "disabled:cursor-not-allowed disabled:opacity-40",
836 )}
837 >
838 <Play className="h-3 w-3" />
839 Generate
840 </button>
841 )}
842 {showRegen && (
843 <button
844 type="button"
845 onClick={() => {
846 setDirty(false);
847 onGenerate();
848 }}
849 className={cn(
850 "inline-flex items-center gap-1 rounded-full px-2.5 py-0.5",
851 "border border-foreground/12 bg-foreground/[0.03]",
852 "text-[10px] font-medium text-foreground/50",
853 "transition-all hover:bg-foreground/[0.06]",
854 )}
855 >
856 <RefreshCcw className="h-2.5 w-2.5" />
857 Regenerate
858 </button>
859 )}
860 {index > 0 && (
861 <div className="flex items-center gap-1.5">
862 <span className="select-none text-[10px] font-medium text-muted-foreground/50">
863 Continuous
864 </span>
865 <Switch
866 checked={frame.continuousEnabled ?? false}
867 disabled={isContinuousLocked(frame.status)}
868 onCheckedChange={(enabled) => onSetContinuousMode?.(enabled)}
869 aria-label="Continuous first frame"
870 />
871 <TooltipProvider delayDuration={200}>
872 <Tooltip>
873 <TooltipTrigger asChild>
874 <button
875 type="button"
876 className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/40 transition-colors hover:text-foreground/60"
877 aria-label="Continuous first frame details"
878 >
879 <HelpCircle className="h-3.5 w-3.5" />
880 </button>
881 </TooltipTrigger>
882 <TooltipContent side="top" className="max-w-[220px] text-xs">
883 Use the previous shot's final frame as this shot's opening frame.
884 </TooltipContent>
885 </Tooltip>
886 </TooltipProvider>
887 </div>
888 )}
889 </div>
890 </div>
891
892 {isIdle(frame.status) && frame.hintMessage ? (
893 <div
894 role="status"
895 className="mx-3.5 mb-2 rounded-lg border border-amber-500/15 bg-amber-500/[0.06] px-2.5 py-2 text-[11px] leading-5 text-amber-700 dark:text-amber-300"
896 >
897 {frame.hintMessage}
898 </div>
899 ) : null}
900
901 {isIdle(frame.status) && frame.dependencyMessage ? (
902 <div
903 role="status"
904 className="mx-3.5 mb-2 rounded-lg border border-amber-500/15 bg-amber-500/[0.06] px-2.5 py-2 text-[11px] leading-5 text-amber-700 dark:text-amber-300"
905 >
906 {frame.dependencyMessage}
907 </div>
908 ) : null}
909
910 {/* {frame.referenceShotIds !== undefined ? (
911 <div className="mx-3.5 mb-2 rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-[11px] leading-5 text-muted-foreground">
912 <p>
913 Reference shots:
914 {frame.referenceShotIds.length > 0
915 ? frame.referenceShotIds.join("、")
916 : "None"}
917 </p>
918 {frame.referenceNote ? (
919 <p className="mt-1 text-foreground/55">{frame.referenceNote}</p>
920 ) : null}
921 {frame.dependencyMessage ? (
922 <p className="mt-1 text-amber-700 dark:text-amber-300">
923 {frame.dependencyMessage}
924 </p>
925 ) : null}
926 </div>
927 ) : null} */}
928
929 {/* ── script preview (+ shot1 first-frame uploader) ── */}
930 <div className="px-3.5">
931 <div className="mb-2">
932 {(isTruncated || textOpen) && !editing && (
933 <button
934 type="button"
935 onClick={() => setTextOpen(!textOpen)}
936 className={cn(
937 "flex w-full items-center gap-1.5 rounded-lg py-1 text-left text-[11px] transition-colors",
938 "text-muted-foreground/45 hover:text-foreground/55",
939 )}
940 >
941 <ChevronDown
942 className={cn(
943 "h-3 w-3 shrink-0 transition-transform duration-200",
944 textOpen && "rotate-180",
945 )}
946 />
947 {textOpen ? "Collapse" : "Expand"}
948 <span className="ml-1 text-[10px] text-muted-foreground/25">
949 {frame.segmentText.length} chars
950 </span>
951 </button>
952 )}
953
954 {!editing && (
955 <div
956 className={cn(
957 firstFrameDisplayUrl && "flex items-center gap-3 pt-1.5 pr-1.5",
958 )}
959 >
960 {firstFrameDisplayUrl ? (
961 <div className="relative h-16 w-16 shrink-0">
962 <div className="relative h-full w-full overflow-hidden rounded-xl border border-foreground/10 bg-foreground/[0.03]">
963 <button
964 type="button"
965 className="h-full w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20"
966 onClick={() => setFirstFrameLightboxOpen(true)}
967 aria-label="Preview first-frame reference"
968 >
969 <img
970 src={firstFrameDisplayUrl}
971 alt=""
972 className="h-full w-full object-cover"
973 draggable={false}
974 />
975 </button>
976 </div>
977 <ImageLightbox
978 images={[
979 {
980 url: firstFrameDisplayUrl,
981 name: "first-frame.jpg",
982 },
983 ]}
984 index={firstFrameLightboxOpen ? 0 : null}
985 onIndexChange={() => {}}
986 onOpenChange={(open) => {
987 if (!open) setFirstFrameLightboxOpen(false);
988 }}
989 />
990 </div>
991 ) : null}
992 <div className="relative min-w-0 flex-1">
993 <div
994 className="pointer-events-none invisible absolute inset-x-0 top-0 -z-10 w-full"
995 aria-hidden
996 >
997 <p
998 ref={clampedMeasureRef}
999 className="mt-0.5 line-clamp-3 text-[13px] leading-[1.7]"
1000 >
1001 {segmentText}
1002 </p>
1003 <p
1004 ref={fullMeasureRef}
1005 className="mt-0.5 text-[13px] leading-[1.7]"
1006 >
1007 {segmentText}
1008 </p>
1009 </div>
1010
1011 {!textOpen && (
1012 <p
1013 className={cn(
1014 "mt-0.5 text-[13px] leading-[1.7] text-foreground/40",
1015 isTruncated && "line-clamp-3",
1016 )}
1017 >
1018 {segmentText}
1019 </p>
1020 )}
1021 {textOpen && (
1022 <p className="mt-0.5 text-[13px] leading-[1.7] text-foreground/40">
1023 {segmentText}
1024 </p>
1025 )}
1026 </div>
1027 </div>
1028 )}
1029 </div>
1030 </div>
1031
1032 {/* ── preview ── */}
1033 <div className="px-3.5 pb-2.5">
1034 {frame.generationMemories && frame.generationMemories.length > 0 ? (
1035 <div className="mb-2.5 rounded-xl border border-foreground/8 bg-foreground/[0.015] px-2.5 py-2">
1036 <div className="mb-1.5 text-[10px] font-medium text-foreground/45">
1037 Memories used for this shot
1038 </div>
1039 <div className="flex gap-2 overflow-x-auto pb-0.5">
1040 {frame.generationMemories.map((memory) => {
1041 const label = memoryDisplayName(memory);
1042 const previewIndex = memoryPreviewIndexById.get(memory.id);
1043 return (
1044 <div
1045 key={memory.id}
1046 className="w-24 shrink-0 overflow-hidden rounded-lg border border-foreground/8 bg-background/70"
1047 >
1048 <button
1049 type="button"
1050 onClick={() => {
1051 if (previewIndex != null) {
1052 setMemoryLightboxIndex(previewIndex);
1053 }
1054 }}
1055 disabled={previewIndex == null}
1056 aria-label={`Preview ${label} memory image`}
1057 className="block w-full text-left disabled:cursor-default"
1058 >
1059 <img
1060 src={memory.image.url}
1061 alt={`${label} Memory`}
1062 className="h-14 w-full object-cover"
1063 />
1064 </button>
1065 <div className="px-1.5 py-1">
1066 <div
1067 className="truncate text-[9px] font-semibold text-foreground/60"
1068 title={label}
1069 >
1070 {label}
1071 </div>
1072 {memory.audio?.url ? (
1073 <audio
1074 src={memory.audio.url}
1075 controls
1076 preload="none"
1077 aria-label={`Play ${label} memory audio`}
1078 className="mt-1 h-5 w-full"
1079 />
1080 ) : null}
1081 </div>
1082 </div>
1083 );
1084 })}
1085 </div>
1086 <ImageLightbox
1087 images={memoryPreviewImages}
1088 index={memoryLightboxIndex}
1089 onIndexChange={setMemoryLightboxIndex}
1090 onOpenChange={(open) => {
1091 if (!open) setMemoryLightboxIndex(null);
1092 }}
1093 />
1094 {/* {nextContinuous ? (
1095 <div className="mt-2 flex items-center gap-1.5 border-t border-border/30 pt-2">
1096 <span className="text-[10px] text-muted-foreground/50">
1097 Shot {index + 2} continuous first frame
1098 </span>
1099 <Switch
1100 checked={nextContinuous.enabled}
1101 disabled={nextContinuous.disabled}
1102 onCheckedChange={nextContinuous.onToggle}
1103 aria-label={`Enable continuous first frame for Shot ${index + 2}`}
1104 />
1105 <TooltipProvider delayDuration={200}>
1106 <Tooltip>
1107 <TooltipTrigger asChild>
1108 <button
1109 type="button"
1110 className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/40 transition-colors hover:text-foreground/60"
1111 aria-label="Continuous first frame details"
1112 >
1113 <HelpCircle className="h-3.5 w-3.5" />
1114 </button>
1115 </TooltipTrigger>
1116 <TooltipContent side="top" className="max-w-[220px] text-xs">
1117 Use the previous shot's final frame as the next opening frame.
1118 </TooltipContent>
1119 </Tooltip>
1120 </TooltipProvider>
1121 </div>
1122 ) : null} */}
1123 </div>
1124 ) : null}
1125 {isDone(frame.status) && displayedVideoUrl ? (
1126 <ShotVideoPlayer src={displayedVideoUrl} />
1127 ) : isDone(frame.status) && !frame.videoUrl ? (
1128 <div className="overflow-hidden rounded-xl border border-foreground/8 bg-foreground/[0.02]">
1129 <div className="flex aspect-video w-full items-center justify-center">
1130 <div className="flex flex-col items-center gap-2.5">
1131 <div className="grid h-12 w-12 place-items-center rounded-full bg-foreground/[0.05] ring-1 ring-inset ring-foreground/[0.08]">
1132 <Check
1133 className="h-5 w-5 text-foreground/35"
1134 strokeWidth={2}
1135 />
1136 </div>
1137 <span className="text-[11px] font-medium text-foreground/40">
1138 Generation complete
1139 </span>
1140 </div>
1141 </div>
1142 </div>
1143 ) : isActive(frame.status) ? (
1144 <div className="relative overflow-hidden rounded-xl border border-foreground/8 bg-foreground/[0.01] shadow-sm">
1145 <div className="relative flex aspect-video w-full items-center justify-center">
1146 <div
1147 className="absolute inset-0"
1148 style={{
1149 background:
1150 "linear-gradient(90deg, transparent 25%, var(--foreground) 50%, transparent 75%)",
1151 backgroundSize: "200% 100%",
1152 opacity: 0.03,
1153 animation: "shot-shimmer 2.5s ease-in-out infinite",
1154 }}
1155 />
1156 <div className="absolute inset-0 bg-[length:28px_28px] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] opacity-[0.06]" />
1157 <div
1158 className="absolute left-0 right-0 h-px bg-gradient-to-r from-transparent via-foreground/15 to-transparent"
1159 style={{ animation: "shot-scan-line 3s linear infinite" }}
1160 />
1161 <div className="relative flex flex-col items-center gap-3">
1162 <div className="relative">
1163 <div
1164 className="absolute -inset-4 rounded-full border border-foreground/[0.06]"
1165 style={{
1166 animation: "shot-pulse-ring 2.5s ease-in-out infinite",
1167 }}
1168 />
1169 <div
1170 className="absolute -inset-7 rounded-full border border-foreground/[0.03]"
1171 style={{
1172 animation:
1173 "shot-pulse-ring 2.5s ease-in-out infinite 0.4s",
1174 }}
1175 />
1176 <Loader2 className="relative h-6 w-6 animate-spin text-foreground/30" />
1177 </div>
1178 <div className="text-center">
1179 <p className="text-[11px] font-medium text-foreground/40">
1180 Generating video
1181 </p>
1182 <p className="mt-0.5 text-[9px] text-muted-foreground/30">
1183 Shot {index + 1} · Please wait
1184 </p>
1185 </div>
1186 </div>
1187 </div>
1188 </div>
1189 ) : isError(frame.status) ? (
1190 <div className="overflow-hidden rounded-xl border border-foreground/10 bg-foreground/[0.015]">
1191 <div className="flex aspect-video w-full items-center justify-center">
1192 <div className="flex flex-col items-center gap-2.5 text-center">
1193 <div className="grid h-12 w-12 place-items-center rounded-full bg-foreground/[0.04] ring-1 ring-inset ring-foreground/[0.08]">
1194 <Circle className="h-5 w-5 text-foreground/25" />
1195 </div>
1196 <div>
1197 <p className="text-[11px] font-medium text-foreground/45">
1198 Generation failed
1199 </p>
1200 <p className="mt-0.5 max-w-[220px] text-[10px] leading-relaxed text-muted-foreground/40">
1201 {frame.error || "Check the request and try again."}
1202 </p>
1203 </div>
1204 </div>
1205 </div>
1206 </div>
1207 ) : (
1208 <div className="overflow-hidden rounded-xl border border-border/25 bg-foreground/[0.01]">
1209 <div className="flex aspect-video w-full items-center justify-center">
1210 <div className="flex flex-col items-center gap-2">
1211 <div className="grid h-10 w-10 place-items-center rounded-full bg-foreground/[0.03] ring-1 ring-inset ring-foreground/[0.05]">
1212 <Film className="h-4 w-4 text-foreground/12" />
1213 </div>
1214 <span className="text-[13px] text-muted-foreground/25">
1215 Use the action above to start production.
1216 </span>
1217 </div>
1218 </div>
1219 </div>
1220 )}
1221 </div>
1222
1223 {frame.reviewNotes ? (
1224 <div className="mx-3.5 mb-2 rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-[11px] leading-5 text-muted-foreground">
1225 {frame.reviewNotes}
1226 </div>
1227 ) : null}
1228 {/* {frame.status === "approved" ? (
1229 <div className="mx-3.5 mb-2 rounded-lg border border-emerald-500/20 bg-emerald-500/8 px-2.5 py-2 text-[11px] text-emerald-800 dark:text-emerald-200">
1230 This shot is approved and will be included in the final cut.
1231 </div>
1232 ) : null} */}
1233 {/* ── accept / revise actions ── */}
1234 {showActions && isDone(frame.status) ? (
1235 <div className="px-3.5 pb-2.5">
1236 {!revising ? (
1237 <div className="flex gap-2">
1238 <button
1239 type="button"
1240 onClick={() => void onAccept()}
1241 disabled={reviewBusy}
1242 className={cn(
1243 "flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg py-2",
1244 "text-[12px] font-medium text-background",
1245 "bg-foreground transition-all duration-200",
1246 "hover:bg-foreground/90",
1247 "active:scale-[0.99]",
1248 "disabled:pointer-events-none disabled:opacity-40",
1249 )}
1250 >
1251 <ThumbsUp className="h-3.5 w-3.5" />
1252 Approve
1253 </button>
1254 <button
1255 type="button"
1256 onClick={() => {
1257 setRevising(true);
1258 setLocalError(null);
1259 }}
1260 disabled={reviewBusy}
1261 className={cn(
1262 "flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2",
1263 "border border-foreground/10 bg-foreground/[0.03]",
1264 "text-[12px] font-medium text-foreground/60",
1265 "transition-all duration-200",
1266 "hover:border-foreground/20 hover:bg-foreground/[0.07] hover:text-foreground/80",
1267 "active:scale-[0.98]",
1268 "disabled:pointer-events-none disabled:opacity-40",
1269 )}
1270 >
1271 <SquarePen className="h-3.5 w-3.5" />
1272 Revise
1273 </button>
1274 </div>
1275 ) : (
1276 <div className="rounded-lg border border-foreground/10 bg-foreground/[0.02] p-2.5">
1277 {memorySlots ? (
1278 <p className="mb-2 text-[10px] leading-4 text-muted-foreground/55">
1279 Adjust the Memory inputs above if needed. The visible draft is
1280 applied before this revision is generated.
1281 </p>
1282 ) : null}
1283 <textarea
1284 ref={reviseRef}
1285 value={reviseFeedback}
1286 onChange={(e) => setReviseFeedback(e.target.value)}
1287 onKeyDown={(e) => {
1288 if (e.key === "Enter" && e.metaKey && reviseFeedback.trim()) {
1289 e.preventDefault();
1290 submitRevise();
1291 }
1292 if (e.key === "Escape") {
1293 setReviseFeedback("");
1294 setRevising(false);
1295 setLocalError(null);
1296 }
1297 }}
1298 placeholder="Describe what should change..."
1299 rows={2}
1300 className={cn(
1301 "w-full resize-none rounded-md bg-background/80 px-2.5 py-2",
1302 "border border-foreground/8",
1303 "text-[13px] leading-relaxed text-foreground/70",
1304 "placeholder:text-muted-foreground/30",
1305 "focus:border-foreground/15 focus:outline-none",
1306 "transition-colors duration-150",
1307 )}
1308 />
1309 {localError ? (
1310 <p className="mt-1.5 text-[10px] text-destructive">
1311 {localError}
1312 </p>
1313 ) : null}
1314 <div className="mt-2 flex items-center justify-between">
1315 <span className="text-[9px] text-muted-foreground/30">
1316 Cmd+Enter submit · Esc cancel
1317 </span>
1318 <div className="flex gap-1.5">
1319 <button
1320 type="button"
1321 onClick={() => {
1322 setReviseFeedback("");
1323 setRevising(false);
1324 setLocalError(null);
1325 }}
1326 disabled={reviewBusy}
1327 className="rounded-md px-2.5 py-1 text-[11px] text-muted-foreground/50 transition-colors hover:bg-foreground/[0.05] hover:text-foreground/60 disabled:opacity-40"
1328 >
1329 Cancel
1330 </button>
1331 <button
1332 type="button"
1333 disabled={!reviseFeedback.trim() || reviewBusy}
1334 onClick={submitRevise}
1335 className={cn(
1336 "flex items-center gap-1 rounded-md px-3 py-1",
1337 "bg-foreground text-[11px] font-medium text-background",
1338 "transition-all duration-150",
1339 "hover:bg-foreground/90 active:scale-[0.97]",
1340 "disabled:opacity-30 disabled:pointer-events-none",
1341 )}
1342 >
1343 <Send className="h-3 w-3" />
1344 Submit
1345 </button>
1346 </div>
1347 </div>
1348 </div>
1349 )}
1350 </div>
1351 ) : null}
1352
1353 <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
1354 <SheetContent
1355 side="left"
1356 className="w-full gap-0 overflow-y-auto sm:max-w-md"
1357 >
1358 <SheetHeader>
1359 <SheetTitle>Shot {index + 1} · Generation Request</SheetTitle>
1360 </SheetHeader>
1361 <div className="mt-4 space-y-4 text-sm">
1362 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-[13px]">
1363 <dt className="text-muted-foreground/60">shot_id</dt>
1364 <dd className="tabular-nums text-foreground/80">
1365 {frame.shotId ?? "—"}
1366 </dd>
1367 <dt className="text-muted-foreground/60">Internal cut</dt>
1368 <dd className="text-foreground/80">
1369 {frame.cut === undefined ? "—" : frame.cut ? "Yes" : "No"}
1370 </dd>
1371 <dt className="text-muted-foreground/60">Duration</dt>
1372 <dd className="tabular-nums text-foreground/80">
1373 {durationSec}s
1374 {frame.numFrames ? ` (${frame.numFrames} frames)` : ""}
1375 </dd>
1376 <dt className="text-muted-foreground/60">Reference frames</dt>
1377 <dd className="tabular-nums text-foreground/80">
1378 {frame.referenceShotIds && frame.referenceShotIds.length > 0
1379 ? frame.referenceShotIds.map((id) => `#${id}`).join("、")
1380 : "None"}
1381 </dd>
1382 </dl>
1383
1384 <div className="space-y-2">
1385 <div className="flex items-center justify-between">
1386 <span className="text-[12px] font-medium text-muted-foreground/70">
1387 Full caption sent to the generation service
1388 </span>
1389 <button
1390 type="button"
1391 onClick={() => {
1392 void navigator.clipboard
1393 ?.writeText(frame.caption ?? "")
1394 .then(() => {
1395 setCaptionCopied(true);
1396 setTimeout(() => setCaptionCopied(false), 1500);
1397 });
1398 }}
1399 className={cn(
1400 "inline-flex items-center gap-1 rounded-md px-2 py-1",
1401 "border border-foreground/10 bg-foreground/[0.03]",
1402 "text-[11px] font-medium text-muted-foreground/60",
1403 "transition-all hover:bg-foreground/[0.06] hover:text-foreground/80",
1404 )}
1405 >
1406 <Copy className="h-3 w-3" />
1407 {captionCopied ? "Copied" : "Copy"}
1408 </button>
1409 </div>
1410 <pre className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-border/40 bg-foreground/[0.02] p-3 font-sans text-[13px] leading-relaxed text-foreground/85 selection:bg-foreground/15">
1411 {frame.caption}
1412 </pre>
1413 </div>
1414 </div>
1415 </SheetContent>
1416 </Sheet>
1417 </article>
1418 </div>
1419 );
1420 }
1421
1421 lines Plain Text