ai-design-system 0.1.104 → 0.1.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ import { SpeechInput, type SpeechInputProps } from "@/components/ai-elements/spe
35
35
  import type { FormEvent } from "react";
36
36
  import { Button } from "@/components/primitives/Button";
37
37
  import { Icon } from "@/components/primitives/Icon";
38
+ import { InputGroupAddon } from "@/components/primitives/InputGroup";
38
39
  import { cn } from "@/lib/utils";
39
40
 
40
41
  export interface PromptInputContextProps {
@@ -93,6 +94,7 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
93
94
  maxFiles,
94
95
  maxFileSize,
95
96
  onError,
97
+ className,
96
98
  ...props
97
99
  }) => {
98
100
  const isRowVariant = variant === "row" || variant === "inline";
@@ -140,30 +142,34 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
140
142
  maxFiles={maxFiles}
141
143
  maxFileSize={maxFileSize}
142
144
  onError={onError}
145
+ className={cn(
146
+ "[&>[data-slot=input-group]]:rounded-2xl",
147
+ className?.includes("border") &&
148
+ "[&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:bg-transparent [&>[data-slot=input-group]]:shadow-none",
149
+ className
150
+ )}
143
151
  {...props}
144
152
  >
145
- <PromptInputBody className="contents">
146
- {showAttachments && <AttachmentPreviews />}
147
- <PromptInputTextarea
148
- placeholder={placeholder}
149
- disabled={disabled}
150
- onChange={isControlled ? handleControlledChange : undefined}
151
- className="min-h-9 py-2 px-3 text-sm resize-none border-0 shadow-none focus-visible:ring-0 bg-transparent flex-1 field-sizing-content"
152
- />
153
- </PromptInputBody>
154
- <div className="pr-1.5 shrink-0 flex items-center self-center">
153
+ {showAttachments && <AttachmentPreviews />}
154
+ <PromptInputTextarea
155
+ placeholder={placeholder}
156
+ disabled={disabled}
157
+ onChange={isControlled ? handleControlledChange : undefined}
158
+ className="min-h-9 py-2 px-3 text-sm resize-none border-0 shadow-none focus-visible:ring-0 bg-transparent flex-1 field-sizing-content"
159
+ />
160
+ <InputGroupAddon align="inline-end" className="self-end pb-1 pr-1.5 shrink-0">
155
161
  <PromptInputSubmit
156
162
  disabled={disabled || (loading && !onStop)}
157
163
  status={loading ? (onStop ? "streaming" : "submitted") : undefined}
158
164
  onClick={isStopping ? (e: React.MouseEvent) => { e.preventDefault(); onStop?.(); } : undefined}
159
165
  className={cn(
160
- "h-8 w-8 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors shrink-0 p-0 flex items-center justify-center cursor-pointer",
166
+ "rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors",
161
167
  isStopping && "bg-transparent hover:bg-accent"
162
168
  )}
163
169
  >
164
170
  {loading ? undefined : <Icon name="corner-down-left" size="sm" />}
165
171
  </PromptInputSubmit>
166
- </div>
172
+ </InputGroupAddon>
167
173
  </AIPromptInput>
168
174
  ) : (
169
175
  <AIPromptInput
@@ -173,6 +179,11 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
173
179
  maxFiles={maxFiles}
174
180
  maxFileSize={maxFileSize}
175
181
  onError={onError}
182
+ className={cn(
183
+ className?.includes("border") &&
184
+ "[&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:bg-transparent [&>[data-slot=input-group]]:shadow-none",
185
+ className
186
+ )}
176
187
  {...props}
177
188
  >
178
189
  <PromptInputBody>
@@ -94,6 +94,26 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
94
94
  return 0;
95
95
  });
96
96
 
97
+ // Dual-video buffer for gapless playback between scenes:
98
+ // One slot actively plays the current scene, while the other slot preloads the next scene in the background.
99
+ const [activeSlot, setActiveSlot] = React.useState<0 | 1>(0);
100
+ const [slot0Index, setSlot0Index] = React.useState<number>(() => {
101
+ if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
102
+ return initialSceneIndex;
103
+ }
104
+ return 0;
105
+ });
106
+ const [slot1Index, setSlot1Index] = React.useState<number>(() => {
107
+ if (scenes.length > 1) {
108
+ const init = initialSceneIndex >= 0 && initialSceneIndex < scenes.length ? initialSceneIndex : 0;
109
+ return (init + 1) % scenes.length;
110
+ }
111
+ return 0;
112
+ });
113
+
114
+ const videoRef0 = React.useRef<HTMLVideoElement | null>(null);
115
+ const videoRef1 = React.useRef<HTMLVideoElement | null>(null);
116
+
97
117
  const [isPlayingState, setIsPlayingState] = React.useState<boolean>(isPlayingProp ?? true);
98
118
  const isPlaying = isPlayingProp !== undefined ? isPlayingProp : isPlayingState;
99
119
 
@@ -111,10 +131,46 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
111
131
  }, [setIsPlaying]);
112
132
 
113
133
  const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
114
- const videoRef = React.useRef<HTMLVideoElement | null>(null);
115
134
 
116
135
  const activeScene = scenes[currentSceneIndex] || scenes[0];
117
136
 
137
+ const goToScene = React.useCallback(
138
+ (targetIdx: number) => {
139
+ if (!scenes.length) return;
140
+ const clampedIdx = (targetIdx + scenes.length) % scenes.length;
141
+ onSceneChange?.(clampedIdx, scenes[clampedIdx]);
142
+ setCurrentSceneIndex(clampedIdx);
143
+
144
+ if (scenes.length <= 1) {
145
+ setSlot0Index(clampedIdx);
146
+ const v0 = videoRef0.current;
147
+ if (v0) {
148
+ v0.currentTime = 0;
149
+ if (isPlaying) v0.play().catch(() => {});
150
+ }
151
+ return;
152
+ }
153
+
154
+ const nextActiveSlot: 0 | 1 = activeSlot === 0 ? 1 : 0;
155
+ const targetVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
156
+ if (targetVideo) {
157
+ targetVideo.currentTime = 0;
158
+ }
159
+ const sceneAfterTarget = (clampedIdx + 1) % scenes.length;
160
+
161
+ if (nextActiveSlot === 0) {
162
+ setSlot0Index(clampedIdx);
163
+ setSlot1Index(sceneAfterTarget);
164
+ } else {
165
+ setSlot1Index(clampedIdx);
166
+ setSlot0Index(sceneAfterTarget);
167
+ }
168
+
169
+ setActiveSlot(nextActiveSlot);
170
+ },
171
+ [scenes, activeSlot, isPlaying, onSceneChange]
172
+ );
173
+
118
174
  const handlePromptSubmit = React.useCallback(
119
175
  (message: PromptInputMessage, event: FormEvent<HTMLFormElement>) => {
120
176
  if (onSubmit) {
@@ -130,30 +186,20 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
130
186
 
131
187
  const handlePrev = React.useCallback(() => {
132
188
  if (!scenes.length) return;
133
- setCurrentSceneIndex((prev) => {
134
- const nextIdx = prev > 0 ? prev - 1 : scenes.length - 1;
135
- onSceneChange?.(nextIdx, scenes[nextIdx]);
136
- return nextIdx;
137
- });
138
- }, [scenes, onSceneChange]);
189
+ const prevIdx = currentSceneIndex > 0 ? currentSceneIndex - 1 : scenes.length - 1;
190
+ goToScene(prevIdx);
191
+ }, [scenes.length, currentSceneIndex, goToScene]);
139
192
 
140
193
  const handleNext = React.useCallback(() => {
141
194
  if (!scenes.length) return;
142
- setCurrentSceneIndex((prev) => {
143
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
144
- onSceneChange?.(nextIdx, scenes[nextIdx]);
145
- return nextIdx;
146
- });
147
- }, [scenes, onSceneChange]);
195
+ const nextIdx = currentSceneIndex < scenes.length - 1 ? currentSceneIndex + 1 : 0;
196
+ goToScene(nextIdx);
197
+ }, [scenes.length, currentSceneIndex, goToScene]);
148
198
 
149
199
  const handleSceneEnded = React.useCallback(() => {
150
200
  if (!scenes.length || !isPlaying) return;
151
- setCurrentSceneIndex((prev) => {
152
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
153
- onSceneChange?.(nextIdx, scenes[nextIdx]);
154
- return nextIdx;
155
- });
156
- }, [scenes, onSceneChange, isPlaying]);
201
+ handleNext();
202
+ }, [scenes.length, isPlaying, handleNext]);
157
203
 
158
204
  const handleDownload = React.useCallback(async () => {
159
205
  if (onDownload) {
@@ -187,28 +233,48 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
187
233
  }
188
234
  }, [onDownload, downloadUrl, videoUrl, activeScene?.scene_clip_url]);
189
235
 
190
- // Playback & mute synchronization
236
+ // Playback & mute synchronization across dual slots
191
237
  React.useEffect(() => {
192
- const video = videoRef.current;
193
- if (!video) return;
238
+ const activeVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
239
+ const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
194
240
 
195
- video.muted = isMuted;
241
+ if (idleVideo) {
242
+ idleVideo.pause();
243
+ idleVideo.muted = true;
244
+ }
245
+
246
+ if (!activeVideo) return;
247
+
248
+ activeVideo.muted = isMuted;
196
249
 
197
250
  if (isPlaying) {
198
- const playPromise = video.play();
251
+ if (activeVideo.ended) {
252
+ activeVideo.currentTime = 0;
253
+ }
254
+ const playPromise = activeVideo.play();
199
255
  if (playPromise !== undefined) {
200
256
  playPromise.catch(() => {
201
257
  if (!isMuted) {
202
- video.muted = true;
258
+ activeVideo.muted = true;
203
259
  setIsMuted(true);
204
- video.play().catch(() => {});
260
+ activeVideo.play().catch(() => {});
205
261
  }
206
262
  });
207
263
  }
208
264
  } else {
209
- video.pause();
265
+ activeVideo.pause();
210
266
  }
211
- }, [activeScene?.scene_clip_url, isMuted, isPlaying]);
267
+ }, [activeSlot, isPlaying, isMuted, slot0Index, slot1Index]);
268
+
269
+ React.useEffect(() => {
270
+ if (!scenes || scenes.length === 0) return;
271
+ if (currentSceneIndex >= scenes.length) {
272
+ setCurrentSceneIndex(0);
273
+ setSlot0Index(0);
274
+ setSlot1Index(scenes.length > 1 ? 1 : 0);
275
+ setActiveSlot(0);
276
+ }
277
+ }, [scenes, currentSceneIndex]);
212
278
 
213
279
  // Keyboard navigation & playback controls
214
280
  React.useEffect(() => {
@@ -367,20 +433,40 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
367
433
 
368
434
  {/* Main Scene Video Player Stage */}
369
435
  <div className="flex-1 relative flex items-center justify-center bg-black overflow-hidden min-h-0">
436
+ {/* Slot 0 Video */}
370
437
  <video
371
- ref={videoRef}
372
- key={activeScene?.scene_clip_url}
373
- src={activeScene?.scene_clip_url}
374
- autoPlay
438
+ ref={videoRef0}
439
+ src={scenes[slot0Index]?.scene_clip_url}
440
+ preload="auto"
375
441
  playsInline
376
- loop={false}
377
- muted={isMuted}
378
- onEnded={handleSceneEnded}
379
- className="w-full h-full object-contain bg-black"
442
+ muted={activeSlot === 0 ? isMuted : true}
443
+ onEnded={activeSlot === 0 ? handleSceneEnded : undefined}
444
+ data-testid="scene-video-0"
445
+ className={cn(
446
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
447
+ activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
448
+ )}
380
449
  />
381
450
 
451
+ {/* Slot 1 Video (preloads next scene in background for gapless playback) */}
452
+ {scenes.length > 1 && (
453
+ <video
454
+ ref={videoRef1}
455
+ src={scenes[slot1Index]?.scene_clip_url}
456
+ preload="auto"
457
+ playsInline
458
+ muted={activeSlot === 1 ? isMuted : true}
459
+ onEnded={activeSlot === 1 ? handleSceneEnded : undefined}
460
+ data-testid="scene-video-1"
461
+ className={cn(
462
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
463
+ activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
464
+ )}
465
+ />
466
+ )}
467
+
382
468
  {/* Invisible Interactive Tap Zones for Stepping Scenes */}
383
- <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-10">
469
+ <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-20">
384
470
  <Button
385
471
  variant="ghost"
386
472
  data-testid="btn-prev-scene"