@hyperframes/studio 0.8.4 → 0.8.5
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/assets/{hyperframes-player-70BfjZRz.js → hyperframes-player-DcM3fVcp.js} +1 -1
- package/dist/assets/{index-KH4Y5JCd.js → index-Cx_UrhoW.js} +5 -5
- package/dist/assets/{index-RQ7S6nvA.js → index-Del6XWZj.js} +1 -1
- package/dist/assets/{index-DEnjB744.js → index-DgJrvYGf.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/storyboard/FramePoster.test.tsx +61 -0
- package/src/components/storyboard/FramePoster.tsx +14 -5
- package/src/components/storyboard/StoryboardFrameFocus.tsx +1 -1
- package/src/player/components/CompositionThumbnail.test.ts +15 -0
- package/src/player/components/CompositionThumbnail.tsx +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/core": "0.8.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/sdk": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
50
|
+
"@hyperframes/core": "0.8.5",
|
|
51
|
+
"@hyperframes/parsers": "0.8.5",
|
|
52
|
+
"@hyperframes/player": "0.8.5",
|
|
53
|
+
"@hyperframes/sdk": "0.8.5",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vite": "^6.4.2",
|
|
69
69
|
"vitest": "^3.2.4",
|
|
70
70
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.8.
|
|
71
|
+
"@hyperframes/producer": "0.8.5"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { FramePoster } from "./FramePoster";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const roots: Root[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
act(() => roots.splice(0).forEach((root) => root.unmount()));
|
|
14
|
+
document.body.replaceChildren();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// A fresh host per render: several cases compare two surfaces side by side.
|
|
18
|
+
function renderPoster(surface?: "tile" | "hero"): HTMLImageElement {
|
|
19
|
+
const host = document.createElement("div");
|
|
20
|
+
document.body.appendChild(host);
|
|
21
|
+
const root = createRoot(host);
|
|
22
|
+
roots.push(root);
|
|
23
|
+
act(() => {
|
|
24
|
+
root.render(
|
|
25
|
+
<FramePoster
|
|
26
|
+
projectId="demo"
|
|
27
|
+
src="frames/01-hero.html"
|
|
28
|
+
seconds={3}
|
|
29
|
+
title="hero"
|
|
30
|
+
surface={surface}
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
const img = host.querySelector("img");
|
|
35
|
+
if (!img) throw new Error("poster did not render an <img>");
|
|
36
|
+
return img;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("FramePoster", () => {
|
|
40
|
+
// Regression: the contact sheet stretched a 240x135 capture across a wide,
|
|
41
|
+
// high-density card, making body copy, thin lines, and sprite details blurry.
|
|
42
|
+
it.each([
|
|
43
|
+
[undefined, "storyboard"],
|
|
44
|
+
["tile", "storyboard"],
|
|
45
|
+
["hero", "source"],
|
|
46
|
+
] as const)("captures the %s surface at %s density", (surface, output) => {
|
|
47
|
+
const url = new URL(renderPoster(surface).src);
|
|
48
|
+
|
|
49
|
+
expect(url.searchParams.get("output")).toBe(output);
|
|
50
|
+
expect(url.pathname).toBe("/api/projects/demo/thumbnail/frames/01-hero.html");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("defaults to the tile surface", () => {
|
|
54
|
+
expect(renderPoster().className).toBe(renderPoster("tile").className);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("letterboxes only the hero, so a tile still fills its cell", () => {
|
|
58
|
+
expect(renderPoster("hero").className).toContain("object-contain");
|
|
59
|
+
expect(renderPoster("tile").className).toContain("object-cover");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -8,8 +8,13 @@ export interface FramePosterProps {
|
|
|
8
8
|
/** Time (seconds) to seek to for the poster. */
|
|
9
9
|
seconds: number;
|
|
10
10
|
title: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Where this poster is rendered. A contact-sheet tile is ~300px wide and there
|
|
13
|
+
* are many of them; the focus hero is up to 900px wide and there is exactly
|
|
14
|
+
* one. Tiles use a bounded high-density capture; the hero uses source density.
|
|
15
|
+
* The surface also decides whether the result fills or letterboxes its cell.
|
|
16
|
+
*/
|
|
17
|
+
surface?: "tile" | "hero";
|
|
13
18
|
/**
|
|
14
19
|
* Project content signature to key the poster URL on. The thumbnail route
|
|
15
20
|
* regenerates when the frame's source changes, but the browser only refetches
|
|
@@ -30,14 +35,14 @@ export function FramePoster({
|
|
|
30
35
|
src,
|
|
31
36
|
seconds,
|
|
32
37
|
title,
|
|
33
|
-
|
|
38
|
+
surface = "tile",
|
|
34
39
|
posterVersion,
|
|
35
40
|
}: FramePosterProps) {
|
|
36
41
|
const [failed, setFailed] = useState(false);
|
|
37
42
|
// The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
|
|
38
43
|
// prior load error would stick. Reset when the poster target changes — including
|
|
39
44
|
// a new posterVersion, so a frame that failed mid-write retries once it settles.
|
|
40
|
-
useEffect(() => setFailed(false), [src, seconds, posterVersion]);
|
|
45
|
+
useEffect(() => setFailed(false), [src, seconds, posterVersion, surface]);
|
|
41
46
|
if (failed) {
|
|
42
47
|
return (
|
|
43
48
|
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
|
@@ -50,6 +55,10 @@ export function FramePoster({
|
|
|
50
55
|
seekTime: seconds,
|
|
51
56
|
duration: 0,
|
|
52
57
|
origin: window.location.origin,
|
|
58
|
+
// The normal 240x135 preview is unreadable in the contact sheet, while source
|
|
59
|
+
// density is unbounded across all tiles. Give tiles a capped review density
|
|
60
|
+
// and reserve true source output for the single focus hero.
|
|
61
|
+
output: surface === "hero" ? "source" : "storyboard",
|
|
53
62
|
});
|
|
54
63
|
if (posterVersion) {
|
|
55
64
|
const withVersion = new URL(url, window.location.origin);
|
|
@@ -63,7 +72,7 @@ export function FramePoster({
|
|
|
63
72
|
draggable={false}
|
|
64
73
|
loading="lazy"
|
|
65
74
|
onError={() => setFailed(true)}
|
|
66
|
-
className={`h-full w-full ${
|
|
75
|
+
className={`h-full w-full ${surface === "hero" ? "object-contain" : "object-cover"}`}
|
|
67
76
|
/>
|
|
68
77
|
);
|
|
69
78
|
}
|
|
@@ -76,6 +76,21 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
76
76
|
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
|
|
77
77
|
);
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it("asks for source density only when a caller opts in", () => {
|
|
81
|
+
const base = {
|
|
82
|
+
previewUrl: "/api/projects/demo/preview",
|
|
83
|
+
seekTime: 1,
|
|
84
|
+
duration: 0,
|
|
85
|
+
origin: "http://localhost:3000",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
expect(buildCompositionThumbnailUrl(base)).not.toContain("output=");
|
|
89
|
+
expect(buildCompositionThumbnailUrl({ ...base, output: "source" })).toContain("output=source");
|
|
90
|
+
expect(buildCompositionThumbnailUrl({ ...base, output: "storyboard" })).toContain(
|
|
91
|
+
"output=storyboard",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
describe("CompositionThumbnail", () => {
|
|
@@ -31,6 +31,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
31
31
|
selector,
|
|
32
32
|
selectorIndex,
|
|
33
33
|
origin,
|
|
34
|
+
output,
|
|
34
35
|
}: {
|
|
35
36
|
previewUrl: string;
|
|
36
37
|
seekTime?: number;
|
|
@@ -38,6 +39,13 @@ export function buildCompositionThumbnailUrl({
|
|
|
38
39
|
selector?: string;
|
|
39
40
|
selectorIndex?: number;
|
|
40
41
|
origin: string;
|
|
42
|
+
/**
|
|
43
|
+
* Capture density. Omitted, the route bounds the image to its preview cap —
|
|
44
|
+
* right for the timeline, where thumbnails are small and numerous and their
|
|
45
|
+
* decoded bytes are budgeted. `"storyboard"` caps the longest side at a
|
|
46
|
+
* high-density review size; `"source"` uses the composition's own dimensions.
|
|
47
|
+
*/
|
|
48
|
+
output?: "source" | "storyboard";
|
|
41
49
|
}): string {
|
|
42
50
|
const thumbnailBase = previewUrl
|
|
43
51
|
.replace("/preview/comp/", "/thumbnail/")
|
|
@@ -45,6 +53,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
45
53
|
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
46
54
|
thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
|
|
47
55
|
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
56
|
+
if (output) thumbnailUrl.searchParams.set("output", output);
|
|
48
57
|
if (selector) {
|
|
49
58
|
thumbnailUrl.searchParams.set("selector", selector);
|
|
50
59
|
if (selectorIndex != null && selectorIndex > 0) {
|