@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10

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.
Files changed (39) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +132 -41
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLEPreview.tsx +5 -1
  15. package/src/components/sidebar/AssetsTab.tsx +3 -4
  16. package/src/player/components/AudioWaveform.tsx +44 -29
  17. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  18. package/src/player/components/CompositionThumbnail.tsx +42 -10
  19. package/src/player/components/EditModal.tsx +5 -20
  20. package/src/player/components/PlayerControls.tsx +120 -11
  21. package/src/player/components/Timeline.test.ts +84 -0
  22. package/src/player/components/Timeline.tsx +198 -27
  23. package/src/player/components/timelineEditing.test.ts +2 -2
  24. package/src/player/components/timelineEditing.ts +1 -1
  25. package/src/player/components/timelineZoom.test.ts +21 -0
  26. package/src/player/components/timelineZoom.ts +11 -0
  27. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  29. package/src/player/lib/time.test.ts +19 -1
  30. package/src/player/lib/time.ts +20 -0
  31. package/src/player/store/playerStore.test.ts +11 -1
  32. package/src/player/store/playerStore.ts +5 -1
  33. package/src/styles/studio.css +9 -0
  34. package/src/utils/clipboard.test.ts +88 -0
  35. package/src/utils/clipboard.ts +57 -0
  36. package/src/utils/timelineAssetDrop.test.ts +64 -4
  37. package/src/utils/timelineAssetDrop.ts +27 -5
  38. package/dist/assets/index-Bi30tos-.js +0 -105
  39. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
108
108
  });
109
109
  });
110
110
 
111
+ it("treats identity transforms left behind by animation libraries as movable", () => {
112
+ expect(
113
+ resolveDomEditCapabilities({
114
+ selector: "#card",
115
+ inlineStyles: {
116
+ left: "120px",
117
+ top: "80px",
118
+ width: "240px",
119
+ height: "140px",
120
+ },
121
+ computedStyles: {
122
+ position: "absolute",
123
+ left: "120px",
124
+ top: "80px",
125
+ width: "240px",
126
+ height: "140px",
127
+ transform: "matrix(1, 0, 0, 1, 0, 0)",
128
+ },
129
+ isCompositionHost: false,
130
+ isMasterView: false,
131
+ }),
132
+ ).toMatchObject({
133
+ canMove: true,
134
+ canResize: true,
135
+ canDetachFromLayout: false,
136
+ });
137
+ });
138
+
139
+ it("treats identity matrix3d transforms as movable", () => {
140
+ expect(
141
+ resolveDomEditCapabilities({
142
+ selector: "#card",
143
+ inlineStyles: {
144
+ left: "120px",
145
+ top: "80px",
146
+ width: "240px",
147
+ height: "140px",
148
+ },
149
+ computedStyles: {
150
+ position: "absolute",
151
+ left: "120px",
152
+ top: "80px",
153
+ width: "240px",
154
+ height: "140px",
155
+ transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
156
+ },
157
+ isCompositionHost: false,
158
+ isMasterView: false,
159
+ }),
160
+ ).toMatchObject({
161
+ canMove: true,
162
+ canResize: true,
163
+ });
164
+ });
165
+
111
166
  it("allows imported absolute media to resize from computed px geometry", () => {
112
167
  expect(
113
168
  resolveDomEditCapabilities({
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
228
283
  expect(selection?.selector).toBe("#card");
229
284
  });
230
285
 
286
+ it("can resolve the exact child when clip-ancestor preference is disabled", () => {
287
+ const document = createDocument(`
288
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
289
+ <p id="copy">Hello</p>
290
+ </section>
291
+ `);
292
+
293
+ const child = document.getElementById("copy") as HTMLElement;
294
+ const selection = resolveDomEditSelection(child, {
295
+ activeCompositionPath: null,
296
+ isMasterView: false,
297
+ preferClipAncestor: false,
298
+ });
299
+
300
+ expect(selection?.id).toBe("copy");
301
+ expect(selection?.selector).toBe("#copy");
302
+ });
303
+
231
304
  it("collects simple child text blocks as separate editable fields", () => {
232
305
  const document = createDocument(`
233
306
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
@@ -394,6 +467,43 @@ describe("patch builders and prompt builder", () => {
394
467
  expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
395
468
  });
396
469
 
470
+ it("uses an absolute source path in copied agent prompts when provided", () => {
471
+ const selection = {
472
+ element: {} as HTMLElement,
473
+ id: "editable-card",
474
+ selector: "#editable-card",
475
+ selectorIndex: undefined,
476
+ sourceFile: "index.html",
477
+ compositionPath: "index.html",
478
+ compositionSrc: undefined,
479
+ isCompositionHost: false,
480
+ label: "Drag me first",
481
+ tagName: "div",
482
+ boundingBox: { x: 108, y: 112, width: 380, height: 196 },
483
+ textContent: "Drag me first",
484
+ dataAttributes: {},
485
+ inlineStyles: {},
486
+ computedStyles: {},
487
+ textFields: [],
488
+ capabilities: {
489
+ canSelect: true,
490
+ canEditStyles: true,
491
+ canMove: true,
492
+ canResize: true,
493
+ canDetachFromLayout: false,
494
+ },
495
+ } satisfies DomEditSelection;
496
+
497
+ const prompt = buildElementAgentPrompt({
498
+ selection,
499
+ currentTime: 1.25,
500
+ sourceFilePath: "/tmp/hf-studio-project/index.html",
501
+ });
502
+
503
+ expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
504
+ expect(prompt).not.toContain("Source file: index.html");
505
+ });
506
+
397
507
  it("serializes child text fields back into HTML", () => {
398
508
  expect(
399
509
  serializeDomEditTextFields([
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
93
93
  return Number.isFinite(parsed) ? parsed : null;
94
94
  }
95
95
 
96
+ function isIdentityTransform(value: string | undefined): boolean {
97
+ const transform = (value ?? "none").trim();
98
+ if (!transform || transform === "none") return true;
99
+
100
+ const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
101
+ if (matrix) {
102
+ const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
103
+ if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
104
+ return (
105
+ Math.abs(values[0] - 1) < 0.0001 &&
106
+ Math.abs(values[1]) < 0.0001 &&
107
+ Math.abs(values[2]) < 0.0001 &&
108
+ Math.abs(values[3] - 1) < 0.0001 &&
109
+ Math.abs(values[4]) < 0.0001 &&
110
+ Math.abs(values[5]) < 0.0001
111
+ );
112
+ }
113
+
114
+ const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
115
+ if (!matrix3d) return false;
116
+ const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
117
+ if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
118
+ const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
119
+ return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
120
+ }
121
+
96
122
  function isClipClassName(className: string | undefined): boolean {
97
123
  return Boolean(className?.split(/\s+/).includes("clip"));
98
124
  }
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
426
452
  const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
427
453
  const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
428
454
  const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
429
- const transform = (args.computedStyles.transform ?? "none").trim();
455
+ const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
430
456
 
431
457
  const canMove =
432
458
  (position === "absolute" || position === "fixed") &&
433
459
  left != null &&
434
460
  top != null &&
435
- transform === "none";
461
+ !hasTransformDrivenGeometry;
436
462
 
437
463
  const canResize = canMove && (width != null || height != null);
438
464
  const isBlockishLayer =
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
442
468
  isBlockishDisplay(args.computedStyles.display);
443
469
  const canDetachFromLayout =
444
470
  !canMove &&
445
- transform === "none" &&
471
+ !hasTransformDrivenGeometry &&
446
472
  isBlockishLayer &&
447
473
  (!isInlineTextTag(args.tagName) || isClipClassName(args.className));
448
474
  const reasonIfDisabled = !canMove
@@ -671,12 +697,15 @@ export function buildElementAgentPrompt({
671
697
  currentTime,
672
698
  tagSnippet,
673
699
  userInstruction,
700
+ sourceFilePath,
674
701
  }: {
675
702
  selection: DomEditSelection;
676
703
  currentTime: number;
677
704
  tagSnippet?: string;
678
705
  userInstruction?: string;
706
+ sourceFilePath?: string;
679
707
  }): string {
708
+ const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
680
709
  const lines = [
681
710
  "## HyperFrames element edit request v1",
682
711
  "Schema version: 1",
@@ -685,7 +714,7 @@ export function buildElementAgentPrompt({
685
714
  "",
686
715
  `Composition: ${selection.compositionPath}`,
687
716
  `Playback time: ${formatTime(currentTime)}`,
688
- `Source file: ${selection.sourceFile}`,
717
+ `Source file: ${displayedSourceFile}`,
689
718
  `DOM id: ${selection.id ?? "(none)"}`,
690
719
  `Selector: ${selection.selector ?? "(none)"}`,
691
720
  `Selector index: ${selection.selectorIndex ?? 0}`,
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
67
67
 
68
68
  return (
69
69
  <div className="flex flex-col h-full min-h-0">
70
- <div className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
70
+ <div
71
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
72
+ tabIndex={0}
73
+ aria-label="Composition preview"
74
+ >
71
75
  {retiringKey && (
72
76
  <Player
73
77
  key={retiringKey}
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
3
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
4
4
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
+ import { copyTextToClipboard } from "../../utils/clipboard";
5
6
 
6
7
  interface AssetsTabProps {
7
8
  projectId: string;
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
298
299
  );
299
300
 
300
301
  const handleCopyPath = useCallback(async (path: string) => {
301
- try {
302
- await navigator.clipboard.writeText(path);
302
+ const copied = await copyTextToClipboard(path);
303
+ if (copied) {
303
304
  setCopiedPath(path);
304
305
  setTimeout(() => setCopiedPath(null), 1500);
305
- } catch {
306
- // ignore
307
306
  }
308
307
  }, []);
309
308
 
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
2
 
3
3
  interface AudioWaveformProps {
4
4
  audioUrl: string;
5
+ waveformUrl?: string;
5
6
  label: string;
6
7
  labelColor: string;
7
8
  }
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
49
50
 
50
51
  // Module-level cache so decoded audio persists across re-renders and re-mounts
51
52
  const peaksCache = new Map<string, number[]>();
53
+ const decodeInFlight = new Map<string, Promise<number[]>>();
52
54
 
53
55
  /**
54
56
  * Audio waveform rendered from real PCM data via Web Audio API.
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
57
59
  */
58
60
  export const AudioWaveform = memo(function AudioWaveform({
59
61
  audioUrl,
62
+ waveformUrl,
60
63
  label,
61
64
  labelColor,
62
65
  }: AudioWaveformProps) {
63
66
  const containerRef = useRef<HTMLDivElement | null>(null);
64
67
  const barsRef = useRef<HTMLDivElement | null>(null);
65
68
  const roRef = useRef<ResizeObserver | null>(null);
66
- const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
69
+ const cacheKey = waveformUrl ?? audioUrl;
70
+ const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
67
71
 
68
- // Fetch + decode audio once
69
72
  useEffect(() => {
70
- if (peaks || !audioUrl) return;
71
-
72
- const ctrl = new AbortController();
73
- fetch(audioUrl, { signal: ctrl.signal })
74
- .then((r) => r.arrayBuffer())
75
- .then((buf) => {
76
- const ctx = new AudioContext();
77
- return ctx.decodeAudioData(buf).finally(() => ctx.close());
78
- })
79
- .then((decoded) => {
80
- if (ctrl.signal.aborted) return;
81
- const channel = decoded.getChannelData(0);
82
- // Extract enough peaks for wide clips (up to 4000 bars)
83
- const p = extractPeaks(channel, 4000);
84
- peaksCache.set(audioUrl, p);
85
- setPeaks(p);
86
- })
87
- .catch(() => {
88
- if (ctrl.signal.aborted) return;
89
- // Fallback to fake waveform
90
- const p = fakePeaks(audioUrl, 4000);
91
- peaksCache.set(audioUrl, p);
92
- setPeaks(p);
93
- });
94
-
95
- return () => ctrl.abort();
96
- }, [audioUrl, peaks]);
73
+ if (peaks || !cacheKey) return;
74
+
75
+ let cancelled = false;
76
+
77
+ let promise = decodeInFlight.get(cacheKey);
78
+ if (!promise) {
79
+ promise = (
80
+ waveformUrl
81
+ ? fetch(waveformUrl)
82
+ .then((r) => r.json())
83
+ .then((d: { peaks?: number[] }) => {
84
+ if (!Array.isArray(d.peaks)) throw new Error("bad response");
85
+ return d.peaks;
86
+ })
87
+ : fetch(audioUrl)
88
+ .then((r) => r.arrayBuffer())
89
+ .then((buf) => {
90
+ const ctx = new AudioContext();
91
+ return ctx.decodeAudioData(buf).finally(() => ctx.close());
92
+ })
93
+ .then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
94
+ )
95
+ .catch(() => fakePeaks(cacheKey, 4000))
96
+ .then((p) => {
97
+ peaksCache.set(cacheKey, p);
98
+ return p;
99
+ })
100
+ .finally(() => decodeInFlight.delete(cacheKey));
101
+
102
+ decodeInFlight.set(cacheKey, promise);
103
+ }
104
+
105
+ promise.then((p) => {
106
+ if (!cancelled) setPeaks(p);
107
+ });
108
+ return () => {
109
+ cancelled = true;
110
+ };
111
+ }, [audioUrl, waveformUrl, cacheKey, peaks]);
97
112
 
98
113
  // Draw bars into the container using innerHTML (fast, zoom-resilient)
99
114
  const draw = useCallback(() => {
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
3
+
4
+ describe("buildCompositionThumbnailUrl", () => {
5
+ it("includes selector and occurrence index for precise element thumbnails", () => {
6
+ expect(
7
+ buildCompositionThumbnailUrl({
8
+ previewUrl: "/api/projects/demo/preview",
9
+ seekTime: 1,
10
+ duration: 2,
11
+ selector: ".card",
12
+ selectorIndex: 2,
13
+ origin: "http://localhost:3000",
14
+ }),
15
+ ).toBe(
16
+ "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
17
+ );
18
+ });
19
+ });
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
7
7
  labelColor: string;
8
8
  accentColor?: string;
9
9
  selector?: string;
10
+ selectorIndex?: number;
10
11
  seekTime?: number;
11
12
  duration?: number;
12
13
  width?: number;
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
16
17
  const CLIP_HEIGHT = 66;
17
18
  const THUMBNAIL_URL_VERSION = "v2";
18
19
 
20
+ export function buildCompositionThumbnailUrl({
21
+ previewUrl,
22
+ seekTime = 2,
23
+ duration = 5,
24
+ selector,
25
+ selectorIndex,
26
+ origin,
27
+ }: {
28
+ previewUrl: string;
29
+ seekTime?: number;
30
+ duration?: number;
31
+ selector?: string;
32
+ selectorIndex?: number;
33
+ origin: string;
34
+ }): string {
35
+ const thumbnailBase = previewUrl
36
+ .replace("/preview/comp/", "/thumbnail/")
37
+ .replace(/\/preview$/, "/thumbnail/index.html");
38
+ const midTime = seekTime + duration / 2;
39
+ const thumbnailUrl = new URL(thumbnailBase, origin);
40
+ thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
41
+ thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
42
+ if (selector) {
43
+ thumbnailUrl.searchParams.set("selector", selector);
44
+ if (selectorIndex != null && selectorIndex > 0) {
45
+ thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
46
+ }
47
+ }
48
+ return thumbnailUrl.toString();
49
+ }
50
+
19
51
  export const CompositionThumbnail = memo(function CompositionThumbnail({
20
52
  previewUrl,
21
53
  label,
22
54
  labelColor,
23
55
  accentColor = "#6B7280",
24
56
  selector,
57
+ selectorIndex,
25
58
  seekTime = 2,
26
59
  duration = 5,
27
60
  }: CompositionThumbnailProps) {
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
48
81
  roRef.current?.disconnect();
49
82
  });
50
83
 
51
- const thumbnailBase = previewUrl
52
- .replace("/preview/comp/", "/thumbnail/")
53
- .replace(/\/preview$/, "/thumbnail/index.html");
54
- const midTime = seekTime + duration / 2;
55
- const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
- thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
- thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
- if (selector) thumbnailUrl.searchParams.set("selector", selector);
59
- const url = thumbnailUrl.toString();
84
+ const url = buildCompositionThumbnailUrl({
85
+ previewUrl,
86
+ seekTime,
87
+ duration,
88
+ selector,
89
+ selectorIndex,
90
+ origin: window.location.origin,
91
+ });
60
92
  const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
61
93
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
62
94
 
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
66
98
  src={url}
67
99
  alt=""
68
100
  draggable={false}
69
- loading="lazy"
101
+ loading="eager"
70
102
  onLoad={(e) => {
71
103
  const img = e.currentTarget;
72
104
  if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { usePlayerStore } from "../store/playerStore";
4
4
  import { formatTime } from "../lib/time";
5
5
  import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
6
+ import { copyTextToClipboard } from "../../utils/clipboard";
6
7
 
7
8
  interface EditPopoverProps {
8
9
  rangeStart: number;
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
62
63
  }, [start, end, elementsInRange, prompt]);
63
64
 
64
65
  const handleCopy = useCallback(async () => {
65
- try {
66
- await navigator.clipboard.writeText(buildClipboardText());
67
- } catch {
68
- const ta = document.createElement("textarea");
69
- ta.value = buildClipboardText();
70
- document.body.appendChild(ta);
71
- ta.select();
72
- document.execCommand("copy");
73
- document.body.removeChild(ta);
74
- }
66
+ const copied = await copyTextToClipboard(buildClipboardText());
67
+ if (!copied) return;
75
68
  setCopiedAgentPrompt(true);
76
69
  setTimeout(() => {
77
70
  setCopiedAgentPrompt(false);
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
82
75
  const handleCopyPrompt = useCallback(async () => {
83
76
  const promptText = buildPromptCopyText(prompt);
84
77
  if (!promptText) return;
85
- try {
86
- await navigator.clipboard.writeText(promptText);
87
- } catch {
88
- const ta = document.createElement("textarea");
89
- ta.value = promptText;
90
- document.body.appendChild(ta);
91
- ta.select();
92
- document.execCommand("copy");
93
- document.body.removeChild(ta);
94
- }
78
+ const copied = await copyTextToClipboard(promptText);
79
+ if (!copied) return;
95
80
  setCopiedPromptOnly(true);
96
81
  setTimeout(() => {
97
82
  setCopiedPromptOnly(false);