@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

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 (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
@@ -1,7 +1,7 @@
1
1
  import { useRef, useCallback } from "react";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
- import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
4
+ import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
5
5
  import { useCaptionStore } from "../../captions/store";
6
6
 
7
7
  interface PlaybackAdapter {
@@ -22,7 +22,7 @@ interface TimelineLike {
22
22
  isActive: () => boolean;
23
23
  }
24
24
 
25
- export interface ClipManifestClip {
25
+ interface ClipManifestClip {
26
26
  id: string | null;
27
27
  label: string;
28
28
  start: number;
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
64
64
  }
65
65
 
66
66
  function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
67
- if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
67
+ const win = el.ownerDocument.defaultView ?? window;
68
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
69
+ const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
70
+ if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
68
71
  const candidate = el.querySelector("video, audio, img");
69
- return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
72
+ return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
70
73
  ? candidate
71
74
  : null;
72
75
  }
@@ -92,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
92
95
  const src = mediaEl.getAttribute("src");
93
96
  if (src) entry.src = src;
94
97
 
95
- if (!(mediaEl instanceof HTMLMediaElement)) return;
98
+ const win = mediaEl.ownerDocument.defaultView ?? window;
99
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
100
+ if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
96
101
 
97
102
  const sourceDurationAttr =
98
103
  el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -165,11 +170,24 @@ export function shouldIgnorePlaybackShortcutEvent(
165
170
  );
166
171
  }
167
172
 
173
+ function getTimelineElementDisplayLabel(input: {
174
+ id?: string | null;
175
+ label?: string | null;
176
+ tag?: string | null;
177
+ }): string {
178
+ const label = input.label?.trim();
179
+ if (label) return label;
180
+ const id = input.id?.trim();
181
+ if (id) return id;
182
+ const tag = input.tag?.trim().toLowerCase();
183
+ return tag ? `${tag} clip` : "Timeline clip";
184
+ }
185
+
168
186
  /**
169
187
  * Parse [data-start] elements from a Document into TimelineElement[].
170
188
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
171
189
  */
172
- function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
190
+ export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
173
191
  const rootComp = doc.querySelector("[data-composition-id]");
174
192
  const nodes = doc.querySelectorAll("[data-start]");
175
193
  const els: TimelineElement[] = [];
@@ -200,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
200
218
  const selector = getTimelineElementSelector(el);
201
219
  const sourceFile = getTimelineElementSourceFile(el);
202
220
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
203
- const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
221
+ const label = getTimelineElementDisplayLabel({
222
+ id: el.id || compId || null,
223
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
224
+ tag: tagLower,
225
+ });
226
+ const identity = buildTimelineElementIdentity({
227
+ preferredId: el.id || compId || null,
228
+ label,
229
+ fallbackIndex: els.length,
230
+ domId: el.id || undefined,
231
+ selector,
232
+ selectorIndex,
233
+ sourceFile,
234
+ });
204
235
  const entry: TimelineElement = {
205
- id,
206
- key: buildTimelineElementKey({
207
- id,
208
- fallbackIndex: els.length,
209
- domId: el.id || undefined,
210
- selector,
211
- selectorIndex,
212
- sourceFile,
213
- }),
236
+ id: identity.id,
237
+ label,
238
+ key: identity.key,
214
239
  tag: tagLower,
215
240
  start,
216
241
  duration: dur,
@@ -311,6 +336,40 @@ function buildTimelineElementKey(params: {
311
336
  return `${scope}:${params.id}:${params.fallbackIndex}`;
312
337
  }
313
338
 
339
+ function buildTimelineElementIdentity(params: {
340
+ preferredId?: string | null;
341
+ label: string;
342
+ fallbackIndex: number;
343
+ domId?: string;
344
+ selector?: string;
345
+ selectorIndex?: number;
346
+ sourceFile?: string;
347
+ }): { id: string; key: string } {
348
+ const id =
349
+ params.preferredId?.trim() ||
350
+ buildTimelineElementKey({
351
+ id: params.label,
352
+ fallbackIndex: params.fallbackIndex,
353
+ domId: params.domId,
354
+ selector: params.selector,
355
+ selectorIndex: params.selectorIndex,
356
+ sourceFile: params.sourceFile,
357
+ });
358
+ const key = buildTimelineElementKey({
359
+ id,
360
+ fallbackIndex: params.fallbackIndex,
361
+ domId: params.domId,
362
+ selector: params.selector,
363
+ selectorIndex: params.selectorIndex,
364
+ sourceFile: params.sourceFile,
365
+ });
366
+ return { id, key };
367
+ }
368
+
369
+ function getTimelineElementIdentity(element: TimelineElement): string {
370
+ return element.key ?? element.id;
371
+ }
372
+
314
373
  function getTimelineDomNodes(doc: Document): Element[] {
315
374
  const rootComp = doc.querySelector("[data-composition-id]");
316
375
  return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
@@ -352,6 +411,103 @@ export function findTimelineDomNodeForClip(
352
411
  return candidates[fallbackIndex] ?? null;
353
412
  }
354
413
 
414
+ export function createTimelineElementFromManifestClip(params: {
415
+ clip: ClipManifestClip;
416
+ fallbackIndex: number;
417
+ doc?: Document | null;
418
+ hostEl?: Element | null;
419
+ }): TimelineElement {
420
+ const { clip, fallbackIndex, doc } = params;
421
+ let hostEl = params.hostEl ?? null;
422
+ const label = getTimelineElementDisplayLabel({
423
+ id: clip.id,
424
+ label: clip.label,
425
+ tag: clip.tagName || clip.kind,
426
+ });
427
+
428
+ let domId: string | undefined;
429
+ let selector: string | undefined;
430
+ let selectorIndex: number | undefined;
431
+ let sourceFile: string | undefined;
432
+
433
+ if (hostEl) {
434
+ domId = hostEl.id || undefined;
435
+ selector = getTimelineElementSelector(hostEl);
436
+ selectorIndex =
437
+ doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
438
+ sourceFile = getTimelineElementSourceFile(hostEl);
439
+ }
440
+
441
+ const identity = buildTimelineElementIdentity({
442
+ preferredId: clip.id,
443
+ label,
444
+ fallbackIndex,
445
+ domId,
446
+ selector,
447
+ selectorIndex,
448
+ sourceFile,
449
+ });
450
+ const entry: TimelineElement = {
451
+ id: identity.id,
452
+ label,
453
+ key: identity.key,
454
+ tag: clip.tagName || clip.kind,
455
+ start: clip.start,
456
+ duration: clip.duration,
457
+ track: clip.track,
458
+ domId,
459
+ selector,
460
+ selectorIndex,
461
+ sourceFile,
462
+ };
463
+
464
+ if (hostEl) {
465
+ applyMediaMetadataFromElement(entry, hostEl);
466
+ }
467
+ if (clip.assetUrl) entry.src = clip.assetUrl;
468
+ if (clip.kind === "composition" && clip.compositionId) {
469
+ let resolvedSrc = clip.compositionSrc;
470
+ if (!resolvedSrc) {
471
+ hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
472
+ resolvedSrc =
473
+ hostEl?.getAttribute("data-composition-src") ??
474
+ hostEl?.getAttribute("data-composition-file") ??
475
+ null;
476
+ }
477
+ if (resolvedSrc) {
478
+ entry.compositionSrc = resolvedSrc;
479
+ } else if (hostEl) {
480
+ const innerVideo = hostEl.querySelector("video[src]");
481
+ if (innerVideo) {
482
+ entry.src = innerVideo.getAttribute("src") || undefined;
483
+ entry.tag = "video";
484
+ }
485
+ }
486
+ if (hostEl) {
487
+ entry.domId = hostEl.id || undefined;
488
+ entry.selector = getTimelineElementSelector(hostEl);
489
+ entry.selectorIndex =
490
+ doc && entry.selector
491
+ ? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
492
+ : undefined;
493
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
494
+ const nextIdentity = buildTimelineElementIdentity({
495
+ preferredId: clip.id,
496
+ label,
497
+ fallbackIndex,
498
+ domId: entry.domId,
499
+ selector: entry.selector,
500
+ selectorIndex: entry.selectorIndex,
501
+ sourceFile: entry.sourceFile,
502
+ });
503
+ entry.id = nextIdentity.id;
504
+ entry.key = nextIdentity.key;
505
+ }
506
+ }
507
+
508
+ return entry;
509
+ }
510
+
355
511
  function findTimelineDomNode(doc: Document, id: string): Element | null {
356
512
  return (
357
513
  doc.getElementById(id) ??
@@ -380,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
380
536
 
381
537
  return {
382
538
  id: params.compositionId,
539
+ label: getTimelineElementDisplayLabel({
540
+ id: params.compositionId,
541
+ tag: params.tagName,
542
+ }),
383
543
  key: buildTimelineElementKey({
384
544
  id: params.compositionId,
385
545
  fallbackIndex: 0,
@@ -397,6 +557,7 @@ export function buildStandaloneRootTimelineElement(params: {
397
557
  sourceFile: compositionSrc,
398
558
  };
399
559
  }
560
+
400
561
  function normalizePreviewViewport(doc: Document, win: Window): void {
401
562
  if (doc.documentElement) {
402
563
  doc.documentElement.style.overflow = "hidden";
@@ -500,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
500
661
  return nextElements;
501
662
  }
502
663
 
503
- const nextIds = new Set(nextElements.map((element) => element.id));
504
- const preserved = currentElements.filter((element) => !nextIds.has(element.id));
664
+ const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
665
+ const preserved = currentElements.filter(
666
+ (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
667
+ );
505
668
  if (preserved.length === 0) return nextElements;
506
669
  return [...nextElements, ...preserved];
507
670
  }
@@ -743,7 +906,7 @@ export function useTimelinePlayer() {
743
906
  (deltaFrames: number) => {
744
907
  const adapter = getAdapter();
745
908
  const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
746
- seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
909
+ seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
747
910
  },
748
911
  [getAdapter, seek],
749
912
  );
@@ -876,72 +1039,16 @@ export function useTimelinePlayer() {
876
1039
  }
877
1040
  const usedHostEls = new Set<Element>();
878
1041
  const els: TimelineElement[] = filtered.map((clip, index) => {
879
- let hostEl = iframeDoc
1042
+ const hostEl = iframeDoc
880
1043
  ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
881
1044
  : null;
882
1045
  if (hostEl) usedHostEls.add(hostEl);
883
- const id = clip.id || clip.label || clip.tagName || "element";
884
- const entry: TimelineElement = {
885
- id,
886
- tag: clip.tagName || clip.kind,
887
- start: clip.start,
888
- duration: clip.duration,
889
- track: clip.track,
890
- };
891
- if (hostEl) {
892
- entry.domId = hostEl.id || undefined;
893
- entry.selector = getTimelineElementSelector(hostEl);
894
- entry.selectorIndex =
895
- iframeDoc && entry.selector
896
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
897
- : undefined;
898
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
899
- applyMediaMetadataFromElement(entry, hostEl);
900
- }
901
- if (clip.assetUrl) entry.src = clip.assetUrl;
902
- if (clip.kind === "composition" && clip.compositionId) {
903
- // The bundler renames data-composition-src to data-composition-file
904
- // after inlining, so the clip manifest may not have compositionSrc.
905
- // Fall back to reading data-composition-file from the DOM.
906
- let resolvedSrc = clip.compositionSrc;
907
- if (!resolvedSrc) {
908
- hostEl =
909
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
910
- resolvedSrc =
911
- hostEl?.getAttribute("data-composition-src") ??
912
- hostEl?.getAttribute("data-composition-file") ??
913
- null;
914
- }
915
- if (resolvedSrc) {
916
- entry.compositionSrc = resolvedSrc;
917
- } else if (hostEl) {
918
- // Inline composition (no external file) — expose inner video for thumbnails
919
- const innerVideo = hostEl.querySelector("video[src]");
920
- if (innerVideo) {
921
- entry.src = innerVideo.getAttribute("src") || undefined;
922
- entry.tag = "video";
923
- }
924
- }
925
- if (hostEl) {
926
- const iframeDoc = iframeRef.current?.contentDocument;
927
- entry.domId = hostEl.id || undefined;
928
- entry.selector = getTimelineElementSelector(hostEl);
929
- entry.selectorIndex =
930
- iframeDoc && entry.selector
931
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
932
- : undefined;
933
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
934
- }
935
- }
936
- entry.key = buildTimelineElementKey({
937
- id,
1046
+ return createTimelineElementFromManifestClip({
1047
+ clip,
938
1048
  fallbackIndex: index,
939
- domId: entry.domId,
940
- selector: entry.selector,
941
- selectorIndex: entry.selectorIndex,
942
- sourceFile: entry.sourceFile,
1049
+ doc: iframeDoc,
1050
+ hostEl,
943
1051
  });
944
- return entry;
945
1052
  });
946
1053
  const rawDuration = data.durationInFrames / 30;
947
1054
  // Clamp non-finite or absurdly large durations — the runtime can emit
@@ -1055,17 +1162,24 @@ export function useTimelinePlayer() {
1055
1162
  const selector = getTimelineElementSelector(el);
1056
1163
  const sourceFile = getTimelineElementSourceFile(el);
1057
1164
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
1058
- const id = el.id || compId;
1165
+ const label = getTimelineElementDisplayLabel({
1166
+ id: el.id || compId || null,
1167
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
1168
+ tag: el.tagName,
1169
+ });
1170
+ const identity = buildTimelineElementIdentity({
1171
+ preferredId: el.id || compId || null,
1172
+ label,
1173
+ fallbackIndex: missing.length,
1174
+ domId: el.id || undefined,
1175
+ selector,
1176
+ selectorIndex,
1177
+ sourceFile,
1178
+ });
1059
1179
  const entry: TimelineElement = {
1060
- id,
1061
- key: buildTimelineElementKey({
1062
- id,
1063
- fallbackIndex: missing.length,
1064
- domId: el.id || undefined,
1065
- selector,
1066
- selectorIndex,
1067
- sourceFile,
1068
- }),
1180
+ id: identity.id,
1181
+ label,
1182
+ key: identity.key,
1069
1183
  tag: el.tagName.toLowerCase(),
1070
1184
  start,
1071
1185
  duration: dur,
@@ -1237,9 +1351,6 @@ export function useTimelinePlayer() {
1237
1351
  setIsPlaying(false);
1238
1352
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1239
1353
 
1240
- const togglePlayRef = useRef(togglePlay);
1241
- togglePlayRef.current = togglePlay;
1242
-
1243
1354
  const refreshPlayer = useCallback(() => {
1244
1355
  const iframe = iframeRef.current;
1245
1356
  if (!iframe) return;
@@ -1348,6 +1459,8 @@ export function useTimelinePlayer() {
1348
1459
  stopRAFLoop();
1349
1460
  stopReverseLoop();
1350
1461
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1462
+ // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1463
+ // to prevent blink. New data will replace old when the iframe reloads.
1351
1464
  };
1352
1465
  });
1353
1466
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
2
+ import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
@@ -72,4 +72,14 @@ describe("frame helpers", () => {
72
72
  it("formats current and total frame display", () => {
73
73
  expect(formatFrameTime(1, 5)).toBe("30f / 150f");
74
74
  });
75
+
76
+ it("steps from a truncated runtime time by integer frame index", () => {
77
+ expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
78
+ expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
79
+ expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
80
+ });
81
+
82
+ it("clamps frame stepping at zero", () => {
83
+ expect(stepFrameTime(0, -1)).toBe(0);
84
+ });
75
85
  });
@@ -19,6 +19,12 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
19
19
  return frame / fps;
20
20
  }
21
21
 
22
+ export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
23
+ const currentFrame = secondsToFrame(time, fps);
24
+ const nextFrame = Math.max(0, currentFrame + deltaFrames);
25
+ return frameToSeconds(nextFrame, fps);
26
+ }
27
+
22
28
  export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
23
29
  const currentFrame = secondsToFrame(time, fps);
24
30
  const totalFrames = secondsToFrame(duration, fps);
@@ -2,6 +2,7 @@ import { create } from "zustand";
2
2
 
3
3
  export interface TimelineElement {
4
4
  id: string;
5
+ label?: string;
5
6
  key?: string;
6
7
  tag: string;
7
8
  start: number;
@@ -49,3 +49,115 @@ body {
49
49
  .cm-editor.cm-focused {
50
50
  outline: none;
51
51
  }
52
+
53
+ /*
54
+ * HyperFrames brand loader. Shared by preview overlays that need a calm,
55
+ * branded loading state instead of a generic spinner.
56
+ */
57
+ .hf-loader {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ justify-content: center;
62
+ gap: 0.75rem;
63
+ width: min(34rem, 100%);
64
+ padding: 1.5rem;
65
+ box-sizing: border-box;
66
+ text-align: center;
67
+ cursor: default;
68
+ user-select: none;
69
+ -webkit-user-select: none;
70
+ -webkit-user-drag: none;
71
+ }
72
+
73
+ .hf-frame {
74
+ display: grid;
75
+ place-items: center;
76
+ width: 100%;
77
+ height: 100%;
78
+ min-height: 12rem;
79
+ border: 1px solid rgba(255, 255, 255, 0.08);
80
+ background: rgba(0, 0, 0, 0.52);
81
+ }
82
+
83
+ .hf-loader-mark-frame {
84
+ display: grid;
85
+ place-items: center;
86
+ overflow: visible;
87
+ transform-origin: 50% 50%;
88
+ user-select: none;
89
+ -webkit-user-select: none;
90
+ -webkit-user-drag: none;
91
+ }
92
+
93
+ .hf-loader-mark {
94
+ display: block;
95
+ overflow: visible;
96
+ filter: drop-shadow(0 0 7px rgba(79, 219, 94, 0.2));
97
+ user-select: none;
98
+ -webkit-user-select: none;
99
+ -webkit-user-drag: none;
100
+ }
101
+
102
+ .hf-loader-title {
103
+ font-family:
104
+ Inter,
105
+ -apple-system,
106
+ BlinkMacSystemFont,
107
+ "Segoe UI",
108
+ sans-serif;
109
+ font-size: 1rem;
110
+ font-weight: 600;
111
+ letter-spacing: 0;
112
+ color: var(--hf-heading, #f4f4f5);
113
+ max-width: 100%;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ white-space: nowrap;
117
+ }
118
+
119
+ .hf-loader-detail {
120
+ max-width: 32rem;
121
+ min-height: 2.5rem;
122
+ overflow: hidden;
123
+ color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68));
124
+ font-family:
125
+ Inter,
126
+ -apple-system,
127
+ BlinkMacSystemFont,
128
+ "Segoe UI",
129
+ sans-serif;
130
+ font-size: 0.82rem;
131
+ line-height: 1.6;
132
+ }
133
+
134
+ .hf-loader-mono {
135
+ width: min(36rem, 100%);
136
+ min-height: 1.5rem;
137
+ overflow: hidden;
138
+ text-overflow: ellipsis;
139
+ white-space: nowrap;
140
+ color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46));
141
+ font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
142
+ font-size: 0.75rem;
143
+ letter-spacing: 0;
144
+ font-variant-numeric: tabular-nums;
145
+ }
146
+
147
+ .hf-loader-progress {
148
+ width: min(18rem, 72vw);
149
+ height: 0.375rem;
150
+ overflow: hidden;
151
+ border-radius: 999px;
152
+ background: rgba(255, 255, 255, 0.1);
153
+ }
154
+
155
+ .hf-loader-progress__fill {
156
+ width: 100%;
157
+ height: 100%;
158
+ transform: scaleX(0);
159
+ transform-origin: left center;
160
+ border-radius: inherit;
161
+ background: linear-gradient(90deg, #06e3fa, #4fdb5e);
162
+ transition: transform 160ms ease;
163
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
3
+
4
+ describe("frame capture utilities", () => {
5
+ it("builds a PNG capture URL for the master composition", () => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
8
+
9
+ expect(
10
+ buildFrameCaptureUrl({
11
+ projectId: "demo project",
12
+ compositionPath: null,
13
+ currentTime: 1.23456,
14
+ origin: "http://localhost:5194",
15
+ }),
16
+ ).toBe(
17
+ "http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
18
+ );
19
+
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ it("builds a safe filename from a nested composition path", () => {
24
+ expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
25
+ });
26
+ });
@@ -0,0 +1,40 @@
1
+ import { buildProjectApiPath } from "./projectRouting";
2
+
3
+ export interface FrameCaptureRequest {
4
+ projectId: string;
5
+ compositionPath: string | null;
6
+ currentTime: number;
7
+ origin?: string;
8
+ }
9
+
10
+ function normalizeCompositionPath(compositionPath: string | null): string {
11
+ return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
12
+ }
13
+
14
+ export function buildFrameCaptureUrl({
15
+ projectId,
16
+ compositionPath,
17
+ currentTime,
18
+ origin = window.location.origin,
19
+ }: FrameCaptureRequest): string {
20
+ const compPath = normalizeCompositionPath(compositionPath);
21
+ const url = new URL(
22
+ buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
23
+ origin,
24
+ );
25
+ url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
26
+ url.searchParams.set("format", "png");
27
+ url.searchParams.set("v", String(Date.now()));
28
+ return url.toString();
29
+ }
30
+
31
+ export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
32
+ const compPath = normalizeCompositionPath(compositionPath);
33
+ const base =
34
+ compPath
35
+ .split("/")
36
+ .pop()
37
+ ?.replace(/\.html$/i, "") || "frame";
38
+ const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
39
+ return `${base}-${frameTime}s.png`;
40
+ }
@@ -1,7 +1,7 @@
1
1
  export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
2
2
  export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
3
  export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
4
- export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
4
+ export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
5
5
  export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
6
6
 
7
7
  export function isMediaFile(path: string): boolean {