@hyperframes/studio 0.6.31 → 0.6.33

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.
@@ -6,16 +6,25 @@ import {
6
6
  getCategoryColors,
7
7
  type BlockCategory,
8
8
  } from "../../utils/blockCategories";
9
- import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
9
+ import { usePlayerStore } from "../../player";
10
+ import { formatTime } from "../../player/lib/time";
11
+ import { useStudioContext } from "../../contexts/StudioContext";
12
+ export interface BlockPreviewInfo {
13
+ videoUrl?: string;
14
+ posterUrl?: string;
15
+ title: string;
16
+ }
10
17
 
11
18
  interface BlocksTabProps {
12
- onAddBlock: (blockName: string) => void;
19
+ onAddBlock?: (blockName: string) => void;
20
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
13
21
  }
14
22
 
15
23
  // fallow-ignore-next-line complexity
16
- export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
24
+ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
17
25
  const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
18
26
  useBlockCatalog();
27
+ const [promptModal, setPromptModal] = useState<{ title: string; prompt: string } | null>(null);
19
28
 
20
29
  if (loading) {
21
30
  return (
@@ -56,7 +65,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps)
56
65
  type="text"
57
66
  value={search}
58
67
  onChange={(e) => setSearch(e.target.value)}
59
- placeholder="Search blocks…"
68
+ placeholder="Search by name, category, or tag…"
60
69
  className="w-full bg-neutral-900 border border-neutral-800 rounded-md pl-7 pr-2 py-1.5 text-[11px] text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-700 transition-colors"
61
70
  />
62
71
  </div>
@@ -98,28 +107,42 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps)
98
107
  >
99
108
  {filteredBlocks.map((block) => {
100
109
  const dur = "duration" in block ? (block.duration as number) : undefined;
101
- const dims =
102
- "dimensions" in block
103
- ? (block.dimensions as { width: number; height: number })
104
- : undefined;
105
110
  return (
106
111
  <BlockCard
107
112
  key={block.name}
108
113
  name={block.name}
109
114
  title={block.title}
115
+ description={block.description}
116
+ blockType={block.type}
110
117
  duration={dur}
111
118
  category={block.category}
112
119
  tags={block.tags}
113
120
  posterUrl={block.preview?.poster}
114
121
  videoUrl={block.preview?.video}
115
- dimensions={dims}
116
- onAdd={() => onAddBlock(block.name)}
122
+ onPreview={onPreviewBlock}
123
+ onShowPrompt={setPromptModal}
124
+ onAdd={
125
+ block.category === "vfx" ||
126
+ block.category === "social" ||
127
+ block.category === "scenes"
128
+ ? () => onAddBlock?.(block.name)
129
+ : undefined
130
+ }
117
131
  />
118
132
  );
119
133
  })}
120
134
  </div>
121
135
  )}
122
136
  </div>
137
+ {promptModal &&
138
+ createPortal(
139
+ <PromptPreviewModal
140
+ title={promptModal.title}
141
+ prompt={promptModal.prompt}
142
+ onClose={() => setPromptModal(null)}
143
+ />,
144
+ document.body,
145
+ )}
123
146
  </div>
124
147
  );
125
148
  });
@@ -153,77 +176,168 @@ function CategoryPill({
153
176
  );
154
177
  }
155
178
 
179
+ interface CompositionContext {
180
+ currentTime: number;
181
+ activeCompPath: string | null;
182
+ elements: Array<{
183
+ id: string;
184
+ start: number;
185
+ duration: number;
186
+ track: number;
187
+ label?: string;
188
+ compositionSrc?: string;
189
+ }>;
190
+ compositionDimensions?: { width: number; height: number };
191
+ }
192
+
193
+ function formatCompositionContext(ctx: CompositionContext): string {
194
+ const lines: string[] = [
195
+ `Playback time: ${formatTime(ctx.currentTime)}`,
196
+ `Active composition: ${ctx.activeCompPath || "index.html"}`,
197
+ ];
198
+ if (ctx.compositionDimensions) {
199
+ lines.push(
200
+ `Dimensions: ${ctx.compositionDimensions.width}x${ctx.compositionDimensions.height}`,
201
+ );
202
+ }
203
+ const visibleNow = ctx.elements.filter(
204
+ (el) => ctx.currentTime >= el.start && ctx.currentTime < el.start + el.duration,
205
+ );
206
+ if (visibleNow.length > 0) {
207
+ lines.push(
208
+ "",
209
+ `Elements visible at ${formatTime(ctx.currentTime)}:`,
210
+ ...visibleNow.map(
211
+ (el) =>
212
+ `- ${el.label || el.id} (track ${el.track}, ${formatTime(el.start)}–${formatTime(el.start + el.duration)}${el.compositionSrc ? `, src: ${el.compositionSrc}` : ""})`,
213
+ ),
214
+ );
215
+ }
216
+ const maxZ = ctx.elements.length > 0 ? Math.max(...ctx.elements.map((_, i) => i + 1)) : 0;
217
+ lines.push("", `Highest track index: ${maxZ}`);
218
+ return lines.join("\n");
219
+ }
220
+
221
+ function buildAgentPrompt(
222
+ title: string,
223
+ name: string,
224
+ description: string,
225
+ category: BlockCategory,
226
+ blockType: string,
227
+ context: CompositionContext,
228
+ ): string {
229
+ const isComponent = blockType === "hyperframes:component";
230
+ const kind = isComponent ? "component" : "block";
231
+ const compositionInfo = formatCompositionContext(context);
232
+
233
+ const categoryPrompts: Record<string, string> = {
234
+ captions: [
235
+ `Using /hyperframes, add the "${title}" caption style (registry: ${name}) to my composition.`,
236
+ `${description}`,
237
+ `Transcribe the audio with /hyperframes-media, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
238
+ ].join("\n\n"),
239
+ vfx: [
240
+ `Using /hyperframes, add the "${title}" VFX (registry: ${name}) as a full-screen overlay on my composition.`,
241
+ `${description}`,
242
+ `This is a WebGL effect that requires chrome://flags/#html-in-canvas. Layer it on top of all content, adjust the shader uniforms and color palette to complement my scene, and set the duration to match the composition length.`,
243
+ ].join("\n\n"),
244
+ transitions: [
245
+ `Using /hyperframes, add the "${title}" transition (registry: ${name}) between my scenes.`,
246
+ `${description}`,
247
+ `Place this transition at the cut point between the current scene and the next. Set the duration to 0.5–1s, position it at the scene boundary on the timeline, and make sure the z-index is above both scenes. Adjust colors to match my palette.`,
248
+ ].join("\n\n"),
249
+ effects: [
250
+ `Using /hyperframes, add the "${title}" effect (registry: ${name}) as an overlay on my composition.`,
251
+ `${description}`,
252
+ `Layer this on top of the current content. Adjust the opacity, colors, and animation timing to enhance the scene without overwhelming the main content.`,
253
+ ].join("\n\n"),
254
+ social: [
255
+ `Using /hyperframes, add the "${title}" template (registry: ${name}) to my composition.`,
256
+ `${description}`,
257
+ `Replace the placeholder text, handle, and avatar with my actual content. Match the typography and colors to my brand. Adjust timing so the elements animate in sync with the voiceover.`,
258
+ ].join("\n\n"),
259
+ data: [
260
+ `Using /hyperframes, add the "${title}" visualization (registry: ${name}) to my composition.`,
261
+ `${description}`,
262
+ `Replace the placeholder data with my actual values and labels. Adjust the color scale, animation stagger timing, and typography to match my composition's design system. Size it to fit the current viewport.`,
263
+ ].join("\n\n"),
264
+ scenes: [
265
+ `Using /hyperframes, add the "${title}" scene (registry: ${name}) to my composition.`,
266
+ `${description}`,
267
+ `Replace all placeholder text, images, and content with my actual material. Match fonts, colors, and layout to my existing design tokens. Set the timeline position and duration to fit the narrative flow.`,
268
+ ].join("\n\n"),
269
+ };
270
+
271
+ const instruction =
272
+ categoryPrompts[category] ??
273
+ [
274
+ `Using /hyperframes, add the "${title}" ${kind} (registry: ${name}) to my composition.`,
275
+ `${description}`,
276
+ `Customize it to match my composition's design and timeline.`,
277
+ ].join("\n\n");
278
+
279
+ return [instruction, "", "## Current composition state", "", compositionInfo].join("\n");
280
+ }
281
+
156
282
  function BlockCard({
157
283
  name,
158
284
  title,
285
+ description,
286
+ blockType,
159
287
  duration,
160
288
  category,
161
289
  tags,
162
290
  posterUrl,
163
291
  videoUrl,
164
- dimensions,
165
292
  onAdd,
293
+ onShowPrompt,
294
+ onPreview,
166
295
  }: {
167
296
  name: string;
168
297
  title: string;
298
+ description: string;
299
+ blockType: string;
169
300
  duration?: number;
170
301
  category: BlockCategory;
171
302
  tags?: string[];
172
303
  posterUrl?: string;
173
304
  videoUrl?: string;
174
- dimensions?: { width: number; height: number };
175
- onAdd: () => void;
305
+ onAdd?: () => void;
306
+ onShowPrompt?: (info: { title: string; prompt: string }) => void;
307
+ onPreview?: (preview: BlockPreviewInfo | null) => void;
176
308
  }) {
177
309
  const [hovered, setHovered] = useState(false);
178
310
  const [adding, setAdding] = useState(false);
179
311
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
180
- const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
181
- const videoRef = useRef<HTMLVideoElement | null>(null);
182
312
  const colors = getCategoryColors(category);
183
313
  const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
184
314
 
185
- const cancelLeave = useCallback(() => {
186
- if (leaveTimer.current) {
187
- clearTimeout(leaveTimer.current);
188
- leaveTimer.current = null;
189
- }
190
- }, []);
191
-
192
315
  const handleEnter = useCallback(() => {
193
- cancelLeave();
194
- hoverTimer.current = setTimeout(() => setHovered(true), 500);
195
- }, [cancelLeave]);
196
-
197
- const dismiss = useCallback(() => {
198
- if (hoverTimer.current) {
199
- clearTimeout(hoverTimer.current);
200
- hoverTimer.current = null;
201
- }
202
- cancelLeave();
203
- setHovered(false);
204
- }, [cancelLeave]);
316
+ hoverTimer.current = setTimeout(() => {
317
+ setHovered(true);
318
+ onPreview?.({ videoUrl, posterUrl, title });
319
+ }, 300);
320
+ }, [onPreview, videoUrl, posterUrl, title]);
205
321
 
206
322
  const handleLeave = useCallback(() => {
207
323
  if (hoverTimer.current) {
208
324
  clearTimeout(hoverTimer.current);
209
325
  hoverTimer.current = null;
210
326
  }
211
- leaveTimer.current = setTimeout(() => setHovered(false), 150);
212
- }, []);
327
+ setHovered(false);
328
+ onPreview?.(null);
329
+ }, [onPreview]);
213
330
 
214
331
  useEffect(() => {
215
- if (!hovered) return;
216
- const onKey = (e: KeyboardEvent) => {
217
- if (e.key === "Escape") dismiss();
332
+ return () => {
333
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
218
334
  };
219
- window.addEventListener("keydown", onKey);
220
- return () => window.removeEventListener("keydown", onKey);
221
- }, [hovered, dismiss]);
335
+ }, []);
222
336
 
223
337
  const handleAdd = useCallback(
224
338
  (e: React.MouseEvent) => {
225
339
  e.stopPropagation();
226
- if (adding) return;
340
+ if (adding || !onAdd) return;
227
341
  setAdding(true);
228
342
  onAdd();
229
343
  setTimeout(() => setAdding(false), 1000);
@@ -231,12 +345,38 @@ function BlockCard({
231
345
  [onAdd, adding],
232
346
  );
233
347
 
234
- const handleDragStart = useCallback(
235
- (e: React.DragEvent) => {
236
- e.dataTransfer.effectAllowed = "copy";
237
- e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name, duration, dimensions }));
348
+ const { activeCompPath, compositionDimensions } = useStudioContext();
349
+
350
+ const handleShowPrompt = useCallback(
351
+ (e: React.MouseEvent) => {
352
+ e.stopPropagation();
353
+ const state = usePlayerStore.getState();
354
+ const context: CompositionContext = {
355
+ currentTime: state.currentTime,
356
+ activeCompPath,
357
+ elements: state.elements.map((el) => ({
358
+ id: el.id,
359
+ start: el.start,
360
+ duration: el.duration,
361
+ track: el.track,
362
+ label: el.label,
363
+ compositionSrc: el.compositionSrc,
364
+ })),
365
+ compositionDimensions: compositionDimensions ?? undefined,
366
+ };
367
+ const prompt = buildAgentPrompt(title, name, description, category, blockType, context);
368
+ onShowPrompt?.({ title, prompt });
238
369
  },
239
- [name, duration, dimensions],
370
+ [
371
+ title,
372
+ name,
373
+ description,
374
+ category,
375
+ blockType,
376
+ activeCompPath,
377
+ compositionDimensions,
378
+ onShowPrompt,
379
+ ],
240
380
  );
241
381
 
242
382
  return (
@@ -244,14 +384,11 @@ function BlockCard({
244
384
  className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
245
385
  onPointerEnter={handleEnter}
246
386
  onPointerLeave={handleLeave}
247
- draggable
248
- onDragStart={handleDragStart}
249
387
  >
250
388
  {/* Thumbnail */}
251
389
  <div className="aspect-video w-full overflow-hidden relative">
252
390
  {hovered && videoUrl ? (
253
391
  <video
254
- ref={videoRef}
255
392
  src={videoUrl}
256
393
  autoPlay
257
394
  muted
@@ -277,14 +414,52 @@ function BlockCard({
277
414
  </div>
278
415
  )}
279
416
 
280
- {/* Add button overlay */}
281
- <button
282
- type="button"
283
- onClick={handleAdd}
284
- className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity"
285
- >
286
- <span className="text-[10px] font-semibold text-white">{adding ? "Added" : "Add"}</span>
287
- </button>
417
+ {/* Action overlay */}
418
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity">
419
+ {onAdd && (
420
+ <button
421
+ type="button"
422
+ onClick={handleAdd}
423
+ title="Add to composition at current time"
424
+ className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-white text-black text-[10px] font-semibold hover:bg-neutral-200 transition-colors"
425
+ >
426
+ <svg
427
+ width="10"
428
+ height="10"
429
+ viewBox="0 0 24 24"
430
+ fill="none"
431
+ stroke="currentColor"
432
+ strokeWidth="2.5"
433
+ >
434
+ <path d="M12 5v14M5 12h14" />
435
+ </svg>
436
+ {adding ? "Added!" : "Add"}
437
+ </button>
438
+ )}
439
+ <button
440
+ type="button"
441
+ onClick={handleShowPrompt}
442
+ title="Generate a prompt to paste into your AI agent"
443
+ className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors ${
444
+ onAdd
445
+ ? "bg-white/15 text-white/90 hover:bg-white/25 text-[9px]"
446
+ : "bg-white text-black hover:bg-neutral-200 text-[10px] font-semibold"
447
+ }`}
448
+ >
449
+ <svg
450
+ width={onAdd ? 9 : 11}
451
+ height={onAdd ? 9 : 11}
452
+ viewBox="0 0 24 24"
453
+ fill="none"
454
+ stroke="currentColor"
455
+ strokeWidth="2"
456
+ >
457
+ <rect x="9" y="9" width="13" height="13" rx="2" />
458
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
459
+ </svg>
460
+ Ask agent
461
+ </button>
462
+ </div>
288
463
 
289
464
  {/* Badges */}
290
465
  <div className="absolute top-1 right-1 flex items-center gap-0.5 pointer-events-none">
@@ -313,72 +488,96 @@ function BlockCard({
313
488
  </span>
314
489
  </div>
315
490
  </div>
491
+ </div>
492
+ );
493
+ }
316
494
 
317
- {/* Fullscreen hover preview */}
318
- {hovered &&
319
- (videoUrl || posterUrl) &&
320
- createPortal(
321
- <div
322
- className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
323
- onClick={dismiss}
324
- onPointerEnter={cancelLeave}
325
- onPointerLeave={handleLeave}
495
+ function PromptPreviewModal({
496
+ title,
497
+ prompt,
498
+ onClose,
499
+ }: {
500
+ title: string;
501
+ prompt: string;
502
+ onClose: () => void;
503
+ }) {
504
+ const [value, setValue] = useState(prompt);
505
+ const [copied, setCopied] = useState(false);
506
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
507
+
508
+ useEffect(() => {
509
+ requestAnimationFrame(() => textareaRef.current?.focus());
510
+ }, []);
511
+
512
+ const handleCopy = useCallback(() => {
513
+ navigator.clipboard.writeText(value);
514
+ setCopied(true);
515
+ setTimeout(() => setCopied(false), 1500);
516
+ }, [value]);
517
+
518
+ return (
519
+ <div
520
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
521
+ onClick={onClose}
522
+ >
523
+ <div
524
+ className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
525
+ onClick={(e) => e.stopPropagation()}
526
+ >
527
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
528
+ <div>
529
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
530
+ <p className="text-xs text-neutral-500 mt-0.5">{title}</p>
531
+ </div>
532
+ <button
533
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
534
+ onClick={onClose}
326
535
  >
327
- <div className="bg-black/80 absolute inset-0" />
328
- <button
329
- type="button"
330
- onClick={dismiss}
331
- className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
536
+ <svg
537
+ width="14"
538
+ height="14"
539
+ viewBox="0 0 24 24"
540
+ fill="none"
541
+ stroke="currentColor"
542
+ strokeWidth="2"
543
+ strokeLinecap="round"
332
544
  >
333
- <svg
334
- width="16"
335
- height="16"
336
- viewBox="0 0 24 24"
337
- fill="none"
338
- stroke="currentColor"
339
- strokeWidth="2"
340
- strokeLinecap="round"
341
- strokeLinejoin="round"
342
- >
343
- <path d="M18 6 6 18" />
344
- <path d="m6 6 12 12" />
345
- </svg>
346
- </button>
347
- <div
348
- className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
349
- style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
350
- onClick={(e) => e.stopPropagation()}
351
- >
352
- <div className="aspect-video bg-neutral-950">
353
- {videoUrl ? (
354
- <video
355
- src={videoUrl}
356
- autoPlay
357
- muted
358
- loop
359
- playsInline
360
- className="w-full h-full object-contain"
361
- />
362
- ) : (
363
- <img src={posterUrl} alt={title} className="w-full h-full object-contain" />
364
- )}
365
- </div>
366
- <div className="bg-neutral-900/95 px-4 py-3">
367
- <div className="text-[14px] font-semibold text-neutral-100">{title}</div>
368
- <div className="flex items-center gap-2 mt-1">
369
- <span className={`w-2 h-2 rounded-full ${colors.dot}`} />
370
- <span className={`text-[11px] ${colors.text}`}>
371
- {BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
372
- </span>
373
- {duration != null && (
374
- <span className="text-[11px] text-neutral-500">{duration}s</span>
375
- )}
376
- </div>
377
- </div>
378
- </div>
379
- </div>,
380
- document.body,
381
- )}
545
+ <line x1="18" y1="6" x2="6" y2="18" />
546
+ <line x1="6" y1="6" x2="18" y2="18" />
547
+ </svg>
548
+ </button>
549
+ </div>
550
+ <div className="flex-1 overflow-y-auto px-5 py-4">
551
+ <p className="text-[11px] text-neutral-500 mb-2">
552
+ Edit the prompt below, then copy and paste into your AI agent
553
+ </p>
554
+ <textarea
555
+ ref={textareaRef}
556
+ value={value}
557
+ onChange={(e) => setValue(e.target.value)}
558
+ onKeyDown={(e) => {
559
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleCopy();
560
+ if (e.key === "Escape") onClose();
561
+ }}
562
+ className="w-full min-h-[240px] text-[11px] text-neutral-200 leading-relaxed font-mono bg-neutral-900/60 rounded-lg p-3 border border-neutral-800 resize-y focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
563
+ />
564
+ </div>
565
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
566
+ <span className="text-[11px] text-neutral-600">
567
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
568
+ </span>
569
+ <button
570
+ className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors ${
571
+ copied
572
+ ? "bg-emerald-500 text-white"
573
+ : "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
574
+ }`}
575
+ onClick={handleCopy}
576
+ >
577
+ {copied ? "Copied!" : "Copy prompt"}
578
+ </button>
579
+ </div>
580
+ </div>
382
581
  </div>
383
582
  );
384
583
  }