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