@hyperframes/studio 0.4.38 → 0.4.39

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.
package/dist/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-18P_dZeo.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-D4-n3yWG.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BLrgRQSu.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.4.38",
3
+ "version": "0.4.39",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.4.38",
36
- "@hyperframes/player": "0.4.38"
35
+ "@hyperframes/core": "0.4.39",
36
+ "@hyperframes/player": "0.4.39"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.4.38"
50
+ "@hyperframes/producer": "0.4.39"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -61,6 +61,10 @@ interface AppToast {
61
61
  tone: "error" | "info";
62
62
  }
63
63
 
64
+ function getTimelineElementLabel(element: TimelineElement): string {
65
+ return element.label || element.id || element.tag;
66
+ }
67
+
64
68
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
65
69
  image: 3,
66
70
  video: 5,
@@ -392,7 +396,7 @@ export function StudioApp() {
392
396
  return (
393
397
  <CompositionThumbnail
394
398
  previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
395
- label={el.id || el.tag}
399
+ label={getTimelineElementLabel(el)}
396
400
  labelColor={style.label}
397
401
  accentColor={style.clip}
398
402
  selector={el.selector}
@@ -408,7 +412,7 @@ export function StudioApp() {
408
412
  return (
409
413
  <CompositionThumbnail
410
414
  previewUrl={activePreviewUrl}
411
- label={el.id || el.tag}
415
+ label={getTimelineElementLabel(el)}
412
416
  labelColor={style.label}
413
417
  accentColor={style.clip}
414
418
  selector={el.selector}
@@ -445,7 +449,7 @@ export function StudioApp() {
445
449
  <AudioWaveform
446
450
  audioUrl={audioUrl}
447
451
  waveformUrl={waveformUrl}
448
- label={el.id || el.tag}
452
+ label={getTimelineElementLabel(el)}
449
453
  labelColor={style.label}
450
454
  />
451
455
  );
@@ -458,7 +462,7 @@ export function StudioApp() {
458
462
  return (
459
463
  <VideoThumbnail
460
464
  videoSrc={mediaSrc}
461
- label={el.id || el.tag}
465
+ label={getTimelineElementLabel(el)}
462
466
  labelColor={style.label}
463
467
  duration={el.duration}
464
468
  />
@@ -469,7 +473,7 @@ export function StudioApp() {
469
473
  return (
470
474
  <CompositionThumbnail
471
475
  previewUrl={`/api/projects/${pid}/preview`}
472
- label={el.id || el.tag}
476
+ label={getTimelineElementLabel(el)}
473
477
  labelColor={style.label}
474
478
  accentColor={style.clip}
475
479
  selector={el.selector}
@@ -1014,7 +1014,10 @@ export const Timeline = memo(function Timeline({
1014
1014
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1015
1015
  const getPreviewElement = useCallback(
1016
1016
  (element: TimelineElement): TimelineElement => {
1017
- if (resizingClip?.element.id === element.id) {
1017
+ if (
1018
+ resizingClip &&
1019
+ (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
1020
+ ) {
1018
1021
  return {
1019
1022
  ...element,
1020
1023
  start: resizingClip.previewStart,
@@ -1242,7 +1245,7 @@ export const Timeline = memo(function Timeline({
1242
1245
  draggedClip?.started === true && draggedElement
1243
1246
  ? getRenderedTimelineElement({
1244
1247
  element: draggedElement,
1245
- draggedElementId: draggedElement.id,
1248
+ draggedElementId: draggedElement.key ?? draggedElement.id,
1246
1249
  previewStart: draggedClip.previewStart,
1247
1250
  previewTrack: draggedClip.previewTrack,
1248
1251
  })
@@ -61,6 +61,7 @@ export const TimelineClip = memo(function TimelineClip({
61
61
  ? theme.clipShadowHover
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
+ const displayLabel = el.label || el.id || el.tag;
64
65
  const showHandles = handleOpacity > 0.01;
65
66
 
66
67
  return (
@@ -93,7 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
93
94
  title={
94
95
  isComposition
95
96
  ? `${el.compositionSrc} \u2022 Double-click to open`
96
- : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
97
+ : `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
97
98
  }
98
99
  onPointerEnter={onHoverStart}
99
100
  onPointerLeave={onHoverEnd}
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
53
53
  }),
54
54
  ).toEqual({ ...element, start: 2.4, track: 3 });
55
55
  });
56
+
57
+ it("uses key before id when matching the dragged clip", () => {
58
+ const element = {
59
+ id: "Card",
60
+ key: "index.html:.card:1",
61
+ tag: "div",
62
+ start: 1,
63
+ duration: 2,
64
+ track: 0,
65
+ };
66
+ expect(
67
+ getRenderedTimelineElement({
68
+ element,
69
+ draggedElementId: "index.html:.card:1",
70
+ previewStart: 2.4,
71
+ previewTrack: 3,
72
+ }),
73
+ ).toEqual({ ...element, start: 2.4, track: 3 });
74
+ });
56
75
  });
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
130
130
  previewStart: number | null;
131
131
  previewTrack: number | null;
132
132
  }): TimelineElement {
133
- if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
133
+ if (
134
+ (element.key ?? element.id) !== draggedElementId ||
135
+ previewStart === null ||
136
+ previewTrack === null
137
+ ) {
134
138
  return element;
135
139
  }
136
140
  return {
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Window } from "happy-dom";
2
3
  import {
3
4
  buildStandaloneRootTimelineElement,
5
+ createTimelineElementFromManifestClip,
6
+ findTimelineDomNodeForClip,
7
+ getTimelineElementSelector,
8
+ parseTimelineFromDOM,
9
+ type ClipManifestClip,
4
10
  mergeTimelineElementsPreservingDowngrades,
5
11
  resolveStandaloneRootCompositionSrc,
6
12
  shouldIgnorePlaybackShortcutEvent,
@@ -27,6 +33,29 @@ function mockKeyboardEvent(
27
33
  };
28
34
  }
29
35
 
36
+ function createDocument(markup: string): Document {
37
+ const window = new Window();
38
+ window.document.body.innerHTML = markup;
39
+ return window.document;
40
+ }
41
+
42
+ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
43
+ return {
44
+ id: null,
45
+ label: "Element",
46
+ start: 0,
47
+ duration: 4,
48
+ track: 0,
49
+ kind: "element",
50
+ tagName: "div",
51
+ compositionId: null,
52
+ parentCompositionId: null,
53
+ compositionSrc: null,
54
+ assetUrl: null,
55
+ ...overrides,
56
+ };
57
+ }
58
+
30
59
  describe("buildStandaloneRootTimelineElement", () => {
31
60
  it("includes selector and source metadata for standalone composition fallback clips", () => {
32
61
  expect(
@@ -39,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
39
68
  }),
40
69
  ).toEqual({
41
70
  id: "hero",
71
+ label: "hero",
42
72
  key: 'scenes/hero.html:[data-composition-id="hero"]:0',
43
73
  tag: "div",
44
74
  start: 0,
@@ -87,6 +117,115 @@ describe("resolveStandaloneRootCompositionSrc", () => {
87
117
  });
88
118
  });
89
119
 
120
+ describe("findTimelineDomNodeForClip", () => {
121
+ it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
122
+ const doc = createDocument(`
123
+ <div data-composition-id="main" data-start="0" data-duration="8">
124
+ <section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
125
+ <div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
126
+ <div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
127
+ </div>
128
+ `);
129
+ const used = new Set<Element>();
130
+
131
+ const first = findTimelineDomNodeForClip(
132
+ doc,
133
+ createClip({ id: "__node__index_2", track: 1 }),
134
+ 1,
135
+ used,
136
+ ) as HTMLElement;
137
+ used.add(first);
138
+ const second = findTimelineDomNodeForClip(
139
+ doc,
140
+ createClip({ id: "__node__index_3", track: 2 }),
141
+ 2,
142
+ used,
143
+ ) as HTMLElement;
144
+
145
+ expect(first.className).toBe("clip duplicate-card first");
146
+ expect(second.className).toBe("clip duplicate-card second");
147
+ expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
148
+ expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
149
+ });
150
+ });
151
+
152
+ describe("anonymous timeline identity", () => {
153
+ it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
154
+ const doc = createDocument(`
155
+ <div data-composition-id="main" data-start="0" data-duration="8">
156
+ <div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
157
+ <div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
158
+ </div>
159
+ `);
160
+
161
+ const elements = parseTimelineFromDOM(doc, 8);
162
+
163
+ expect(elements).toHaveLength(2);
164
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
165
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
166
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
167
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
168
+ });
169
+
170
+ it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
171
+ const doc = createDocument(`
172
+ <div data-composition-id="main" data-start="0" data-duration="8">
173
+ <div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
174
+ <div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
175
+ </div>
176
+ `);
177
+ const clips = [
178
+ createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
179
+ createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
180
+ ];
181
+ const used = new Set<Element>();
182
+ const elements = clips.map((clip, index) => {
183
+ const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
184
+ if (hostEl) used.add(hostEl);
185
+ return createTimelineElementFromManifestClip({
186
+ clip,
187
+ fallbackIndex: index,
188
+ doc,
189
+ hostEl,
190
+ });
191
+ });
192
+
193
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
194
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
195
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
196
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
197
+ });
198
+
199
+ it("reads media metadata from owner-window media elements", () => {
200
+ const doc = createDocument(`
201
+ <div data-composition-id="main" data-start="0" data-duration="8">
202
+ <div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
203
+ <video src="/clip.mp4" data-source-duration="12"></video>
204
+ </div>
205
+ </div>
206
+ `);
207
+ const hostEl = doc.querySelector(".video-card");
208
+ const video = hostEl?.querySelector("video");
209
+ if (!hostEl || !video) throw new Error("missing video test fixture");
210
+ Object.defineProperty(video, "defaultPlaybackRate", {
211
+ value: 1.5,
212
+ configurable: true,
213
+ });
214
+
215
+ const element = createTimelineElementFromManifestClip({
216
+ clip: createClip({ kind: "video", tagName: "div" }),
217
+ fallbackIndex: 0,
218
+ doc,
219
+ hostEl,
220
+ });
221
+
222
+ expect(element.tag).toBe("video");
223
+ expect(element.src).toBe("/clip.mp4");
224
+ expect(element.sourceDuration).toBe(12);
225
+ expect(element.playbackRate).toBe(1.5);
226
+ });
227
+ });
228
+
90
229
  describe("mergeTimelineElementsPreservingDowngrades", () => {
91
230
  it("preserves missing current elements when a shorter manifest arrives", () => {
92
231
  expect(
@@ -115,6 +254,65 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
115
254
  ),
116
255
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
117
256
  });
257
+
258
+ it("preserves distinct anonymous clips that share the same friendly id label", () => {
259
+ expect(
260
+ mergeTimelineElementsPreservingDowngrades(
261
+ [
262
+ {
263
+ id: "Card",
264
+ key: "index.html:.card:0",
265
+ label: "Card",
266
+ tag: "div",
267
+ start: 0,
268
+ duration: 3,
269
+ track: 0,
270
+ },
271
+ {
272
+ id: "Card",
273
+ key: "index.html:.card:1",
274
+ label: "Card",
275
+ tag: "div",
276
+ start: 3,
277
+ duration: 3,
278
+ track: 1,
279
+ },
280
+ ],
281
+ [
282
+ {
283
+ id: "Card",
284
+ key: "index.html:.card:0",
285
+ label: "Card",
286
+ tag: "div",
287
+ start: 0,
288
+ duration: 3,
289
+ track: 0,
290
+ },
291
+ ],
292
+ 8,
293
+ 8,
294
+ ),
295
+ ).toEqual([
296
+ {
297
+ id: "Card",
298
+ key: "index.html:.card:0",
299
+ label: "Card",
300
+ tag: "div",
301
+ start: 0,
302
+ duration: 3,
303
+ track: 0,
304
+ },
305
+ {
306
+ id: "Card",
307
+ key: "index.html:.card:1",
308
+ label: "Card",
309
+ tag: "div",
310
+ start: 3,
311
+ duration: 3,
312
+ track: 1,
313
+ },
314
+ ]);
315
+ });
118
316
  });
119
317
 
120
318
  describe("shouldIgnorePlaybackShortcutTarget", () => {