| 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 |