@hyperframes/studio 0.4.41 → 0.4.42

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-D4-n3yWG.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-Dzq4sUj7.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.41",
3
+ "version": "0.4.42",
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.41",
36
- "@hyperframes/player": "0.4.41"
35
+ "@hyperframes/player": "0.4.42",
36
+ "@hyperframes/core": "0.4.42"
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.41"
50
+ "@hyperframes/producer": "0.4.42"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  shouldHandleTimelineToggleHotkey,
50
50
  } from "./utils/timelineDiscovery";
51
51
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
+ import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
52
53
  import { Camera } from "./icons/SystemIcons";
53
54
 
54
55
  interface EditingFile {
@@ -122,9 +123,9 @@ export function StudioApp() {
122
123
  const [resolving, setResolving] = useState(true);
123
124
 
124
125
  useMountEffect(() => {
125
- const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
126
- if (hashMatch) {
127
- setProjectId(hashMatch[1]);
126
+ const hashProjectId = parseProjectIdFromHash(window.location.hash);
127
+ if (hashProjectId) {
128
+ setProjectId(hashProjectId);
128
129
  setResolving(false);
129
130
  return;
130
131
  }
@@ -135,7 +136,7 @@ export function StudioApp() {
135
136
  const first = (data.projects ?? [])[0];
136
137
  if (first) {
137
138
  setProjectId(first.id);
138
- window.location.hash = `#project/${first.id}`;
139
+ window.location.hash = buildProjectHash(first.id);
139
140
  }
140
141
  })
141
142
  .catch(() => {})
@@ -1,4 +1,5 @@
1
1
  import { forwardRef, useRef, useState } from "react";
2
+ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
2
3
  import { useMountEffect } from "../../hooks/useMountEffect";
3
4
  // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
4
5
  // at module load, which throws under SSR. Defer the import to the mount effect
@@ -15,30 +16,6 @@ interface HyperframesPlayerElement extends HTMLElement {
15
16
  iframeElement: HTMLIFrameElement;
16
17
  }
17
18
 
18
- /**
19
- * Readiness check for a Lottie animation instance. Duck-types both supported
20
- * player shapes:
21
- *
22
- * - `lottie-web` exposes a boolean `isLoaded` on `AnimationItem`.
23
- * - `@dotlottie/player-component` doesn't; we infer readiness from
24
- * `totalFrames > 0` since that value is only populated once the animation
25
- * JSON has been parsed.
26
- *
27
- * Kept in sync with the runtime adapter's own checks in
28
- * `@hyperframes/core/runtime/adapters/lottie.ts` — that module would be a
29
- * more canonical home for the helper, but importing from the core package's
30
- * root index pulls Node-only submodules (path, url) into this browser bundle
31
- * and breaks Vite. If the helper grows, split a browser-safe submodule
32
- * export in core and switch this to import it.
33
- */
34
- function isLottieAnimationReady(anim: unknown): boolean {
35
- if (typeof anim !== "object" || anim === null) return true;
36
- const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
37
- if (maybe.isLoaded === true) return true;
38
- if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
39
- return false;
40
- }
41
-
42
19
  // Assets are considered ready when every `<video>`/`<audio>` has enough data
43
20
  // to play through without buffering, and every registered Lottie animation has
44
21
  // finished loading.
@@ -62,7 +39,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
62
39
  const lotties = win.__hfLottie;
63
40
  if (lotties?.length) {
64
41
  for (const anim of lotties) {
65
- if (!isLottieAnimationReady(anim)) return true;
42
+ if (!isLottieAnimationLoaded(anim)) return true;
66
43
  }
67
44
  }
68
45
 
@@ -1,3 +1,5 @@
1
+ import { buildProjectApiPath } from "./projectRouting";
2
+
1
3
  export interface FrameCaptureRequest {
2
4
  projectId: string;
3
5
  compositionPath: string | null;
@@ -17,7 +19,7 @@ export function buildFrameCaptureUrl({
17
19
  }: FrameCaptureRequest): string {
18
20
  const compPath = normalizeCompositionPath(compositionPath);
19
21
  const url = new URL(
20
- `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
22
+ buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
21
23
  origin,
22
24
  );
23
25
  url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildFrameCaptureUrl } from "./frameCapture";
3
+ import {
4
+ buildProjectApiPath,
5
+ buildProjectHash,
6
+ encodeProjectId,
7
+ parseProjectIdFromHash,
8
+ } from "./projectRouting";
9
+
10
+ describe("project routing utilities", () => {
11
+ it("decodes project ids from hash routes before building capture URLs", () => {
12
+ vi.useFakeTimers();
13
+ vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
14
+
15
+ const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");
16
+
17
+ expect(projectId).toBe("Notion Showcase");
18
+ expect(
19
+ buildFrameCaptureUrl({
20
+ projectId: projectId ?? "",
21
+ compositionPath: null,
22
+ currentTime: 1.809,
23
+ origin: "http://localhost:3002",
24
+ }),
25
+ ).toBe(
26
+ "http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
27
+ );
28
+
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ it("accepts legacy raw-space hash routes", () => {
33
+ expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
34
+ });
35
+
36
+ it("decodes reserved characters when the hash route is encoded", () => {
37
+ expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
38
+ });
39
+
40
+ it("does not throw on malformed percent escapes in hash routes", () => {
41
+ expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
42
+ });
43
+
44
+ it("ignores non-project hash routes", () => {
45
+ expect(parseProjectIdFromHash("")).toBeNull();
46
+ expect(parseProjectIdFromHash("#settings")).toBeNull();
47
+ expect(parseProjectIdFromHash("#project/")).toBeNull();
48
+ expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
49
+ });
50
+
51
+ it("encodes project ids when writing hash routes", () => {
52
+ expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
53
+ expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
54
+ expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
55
+ });
56
+
57
+ it("round-trips unicode project ids through hash routes", () => {
58
+ const hash = buildProjectHash("Mañana demo");
59
+
60
+ expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
61
+ expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
62
+ });
63
+
64
+ it("encodes project ids as one API path segment", () => {
65
+ expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
66
+ expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
67
+ expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
68
+ });
69
+
70
+ it("builds API paths without double encoding decoded project ids", () => {
71
+ expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
72
+ "/api/projects/Notion%20Showcase/thumbnail/index.html",
73
+ );
74
+ });
75
+
76
+ it("keeps literal percent signs safe in API paths", () => {
77
+ expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
78
+ "/api/projects/Percent%2520Name/preview",
79
+ );
80
+ });
81
+
82
+ it("keeps unicode project ids safe in API paths", () => {
83
+ expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
84
+ "/api/projects/Ma%C3%B1ana%20demo/preview",
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,27 @@
1
+ const PROJECT_HASH_PREFIX = "#project/";
2
+
3
+ export function encodeProjectId(projectId: string): string {
4
+ return encodeURIComponent(projectId);
5
+ }
6
+
7
+ export function buildProjectHash(projectId: string): string {
8
+ return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
9
+ }
10
+
11
+ export function parseProjectIdFromHash(hash: string): string | null {
12
+ if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
13
+
14
+ const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
15
+ if (!encodedProjectId || encodedProjectId.includes("/")) return null;
16
+
17
+ try {
18
+ return decodeURIComponent(encodedProjectId);
19
+ } catch {
20
+ return encodedProjectId;
21
+ }
22
+ }
23
+
24
+ export function buildProjectApiPath(projectId: string, suffix = ""): string {
25
+ const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
26
+ return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
27
+ }