@hyperframes/studio 0.8.4 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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.4",
51
- "@hyperframes/player": "0.8.4",
52
- "@hyperframes/parsers": "0.8.4",
53
- "@hyperframes/sdk": "0.8.4",
54
- "@hyperframes/studio-server": "0.8.4"
50
+ "@hyperframes/core": "0.8.6",
51
+ "@hyperframes/parsers": "0.8.6",
52
+ "@hyperframes/player": "0.8.6",
53
+ "@hyperframes/sdk": "0.8.6",
54
+ "@hyperframes/studio-server": "0.8.6"
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.4"
71
+ "@hyperframes/producer": "0.8.6"
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
- /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
12
- fit?: "cover" | "contain";
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
- fit = "cover",
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 ${fit === "contain" ? "object-contain" : "object-cover"}`}
75
+ className={`h-full w-full ${surface === "hero" ? "object-contain" : "object-cover"}`}
67
76
  />
68
77
  );
69
78
  }
@@ -224,7 +224,7 @@ export function StoryboardFrameFocus({
224
224
  src={frame.src}
225
225
  seconds={posterTime(frame)}
226
226
  title={title}
227
- fit="contain"
227
+ surface="hero"
228
228
  posterVersion={posterVersion}
229
229
  />
230
230
  ) : (
@@ -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) {
@@ -1,12 +1,19 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useEffect } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
2
6
  import {
3
7
  buildExpandedElements,
4
8
  resolveTimelineExpansionRawId,
9
+ useExpandedTimelineElements,
5
10
  } from "./useExpandedTimelineElements";
6
11
  import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
7
- import type { TimelineElement } from "../store/playerStore";
12
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
13
  import type { ClipManifestClip } from "../lib/playbackTypes";
9
14
 
15
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
16
+
10
17
  const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
11
18
  id: "x",
12
19
  label: "x",
@@ -31,6 +38,19 @@ const el = (over: Partial<TimelineElement>): TimelineElement => ({
31
38
  ...over,
32
39
  });
33
40
 
41
+ function TimelineExpansionHarness({ onValue }: { onValue: (value: TimelineElement[]) => void }) {
42
+ const value = useExpandedTimelineElements();
43
+ useEffect(() => {
44
+ onValue(value);
45
+ }, [onValue, value]);
46
+ return null;
47
+ }
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = "";
51
+ usePlayerStore.getState().reset();
52
+ });
53
+
34
54
  describe("buildExpandedElements", () => {
35
55
  it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
36
56
  // host s3 at absolute 16 → stats-panel.html; children live in that file.
@@ -357,7 +377,7 @@ describe("buildExpandedElements", () => {
357
377
  it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
358
378
  // Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
359
379
  // seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
360
- // so the whole lane went with the host row whenever the paused drill-in
380
+ // so the whole lane went with the host row whenever the store-time drill-in
361
381
  // substituted it for its children.
362
382
  const elements = [
363
383
  el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
@@ -371,7 +391,6 @@ describe("buildExpandedElements", () => {
371
391
  for (const currentTime of [0, 7.68, 0.2]) {
372
392
  const rawId = resolveTimelineExpansionRawId({
373
393
  selectedElementId: null,
374
- isPlaying: false,
375
394
  currentTime,
376
395
  manifest,
377
396
  parentMap,
@@ -424,13 +443,12 @@ describe("buildExpandedElements", () => {
424
443
  });
425
444
 
426
445
  describe("resolveTimelineExpansionRawId", () => {
427
- it("returns null when paused inside a childless top-level clip", () => {
446
+ it("returns null inside a childless top-level clip", () => {
428
447
  const manifest = [clip({ id: "title", start: 0, duration: 4 })];
429
448
 
430
449
  expect(
431
450
  resolveTimelineExpansionRawId({
432
451
  selectedElementId: null,
433
- isPlaying: false,
434
452
  currentTime: 2,
435
453
  manifest,
436
454
  parentMap: new Map(),
@@ -438,7 +456,7 @@ describe("resolveTimelineExpansionRawId", () => {
438
456
  ).toBeNull();
439
457
  });
440
458
 
441
- it("auto-expands an active composition with children when paused and nothing is selected", () => {
459
+ it("auto-expands an active composition with children when nothing is selected", () => {
442
460
  const manifest = [
443
461
  clip({ id: "scene", start: 1, duration: 5 }),
444
462
  clip({ id: "headline", start: 1.5, duration: 2 }),
@@ -448,7 +466,6 @@ describe("resolveTimelineExpansionRawId", () => {
448
466
  expect(
449
467
  resolveTimelineExpansionRawId({
450
468
  selectedElementId: null,
451
- isPlaying: false,
452
469
  currentTime: 2,
453
470
  manifest,
454
471
  parentMap,
@@ -468,7 +485,6 @@ describe("resolveTimelineExpansionRawId", () => {
468
485
  expect(
469
486
  resolveTimelineExpansionRawId({
470
487
  selectedElementId: null,
471
- isPlaying: false,
472
488
  currentTime: 12,
473
489
  manifest,
474
490
  parentMap,
@@ -491,7 +507,6 @@ describe("resolveTimelineExpansionRawId", () => {
491
507
  expect(
492
508
  resolveTimelineExpansionRawId({
493
509
  selectedElementId: null,
494
- isPlaying: false,
495
510
  currentTime: 5,
496
511
  manifest,
497
512
  parentMap,
@@ -499,7 +514,7 @@ describe("resolveTimelineExpansionRawId", () => {
499
514
  ).toBe("second");
500
515
  });
501
516
 
502
- it("auto-expands the innermost active nested composition when paused", () => {
517
+ it("auto-expands the innermost active nested composition", () => {
503
518
  const manifest = [
504
519
  clip({ id: "outer", start: 0, duration: 10 }),
505
520
  clip({ id: "inner", start: 2, duration: 5 }),
@@ -513,7 +528,6 @@ describe("resolveTimelineExpansionRawId", () => {
513
528
  expect(
514
529
  resolveTimelineExpansionRawId({
515
530
  selectedElementId: null,
516
- isPlaying: false,
517
531
  currentTime: 3.5,
518
532
  manifest,
519
533
  parentMap,
@@ -521,7 +535,7 @@ describe("resolveTimelineExpansionRawId", () => {
521
535
  ).toBe("inner");
522
536
  });
523
537
 
524
- it("does not auto-expand an active composition while playing", () => {
538
+ it("resolves an active composition from the current store time", () => {
525
539
  const manifest = [
526
540
  clip({ id: "scene", start: 0, duration: 5 }),
527
541
  clip({ id: "headline", start: 1, duration: 2 }),
@@ -531,15 +545,49 @@ describe("resolveTimelineExpansionRawId", () => {
531
545
  expect(
532
546
  resolveTimelineExpansionRawId({
533
547
  selectedElementId: null,
534
- isPlaying: true,
535
548
  currentTime: 2,
536
549
  manifest,
537
550
  parentMap,
538
551
  }),
539
- ).toBeNull();
552
+ ).toBe("scene");
553
+ });
554
+
555
+ it("keeps inline children visible when the master timeline is playing", () => {
556
+ const elements = [
557
+ el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 5 }),
558
+ ];
559
+ const manifest = [
560
+ clip({ id: "scene", start: 0, duration: 5, compositionSrc: "scene.html" }),
561
+ clip({ id: "headline", start: 1, duration: 2 }),
562
+ ];
563
+ const parentMap = new Map([["headline", "scene"]]);
564
+ let rows: TimelineElement[] | undefined;
565
+ const host = document.createElement("div");
566
+ document.body.append(host);
567
+ const root = createRoot(host);
568
+
569
+ act(() => {
570
+ usePlayerStore.setState({
571
+ elements,
572
+ clipManifest: manifest,
573
+ clipParentMap: parentMap,
574
+ currentTime: 2,
575
+ isPlaying: true,
576
+ });
577
+ root.render(
578
+ React.createElement(TimelineExpansionHarness, {
579
+ onValue: (value) => (rows = value),
580
+ }),
581
+ );
582
+ });
583
+
584
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
585
+ expect(rows?.map((row) => row.domId ?? row.id)).toEqual(["scene", "headline"]);
586
+
587
+ act(() => root.unmount());
540
588
  });
541
589
 
542
- it("keeps selected elements ahead of paused active composition auto-expansion", () => {
590
+ it("keeps selected elements ahead of active composition auto-expansion", () => {
543
591
  const manifest = [
544
592
  clip({ id: "scene", start: 0, duration: 6 }),
545
593
  clip({ id: "headline", start: 1, duration: 2 }),
@@ -553,7 +601,6 @@ describe("resolveTimelineExpansionRawId", () => {
553
601
  expect(
554
602
  resolveTimelineExpansionRawId({
555
603
  selectedElementId: "caption",
556
- isPlaying: false,
557
604
  currentTime: 1.5,
558
605
  manifest,
559
606
  parentMap,
@@ -35,7 +35,6 @@ function resolveRawId(
35
35
 
36
36
  interface TimelineExpansionRawIdInput {
37
37
  selectedElementId: string | null;
38
- isPlaying: boolean;
39
38
  currentTime: number;
40
39
  manifest: ClipManifestClip[];
41
40
  parentMap: Map<string, string>;
@@ -97,14 +96,12 @@ function findActiveExpandableCompositionId(
97
96
 
98
97
  export function resolveTimelineExpansionRawId({
99
98
  selectedElementId,
100
- isPlaying,
101
99
  currentTime,
102
100
  manifest,
103
101
  parentMap,
104
102
  }: TimelineExpansionRawIdInput): string | null {
105
103
  const selectedRawId = resolveRawId(selectedElementId, manifest, parentMap);
106
104
  if (selectedRawId) return selectedRawId;
107
- if (isPlaying) return null;
108
105
  return findActiveExpandableCompositionId(currentTime, manifest, parentMap);
109
106
  }
110
107
 
@@ -334,7 +331,7 @@ export function buildExpandedElements(
334
331
  : (el.key ?? el.id) === parentKey;
335
332
 
336
333
  // ADDITIVE drill-in: the host row stays and its children are appended under
337
- // it. Expansion is also triggered by the playhead alone (paused auto-expand),
334
+ // it. Expansion is also triggered by the playhead alone (active auto-expand),
338
335
  // so substituting the host row made it vanish on an ordinary seek, and with
339
336
  // it the host's keyframe lane, since diamonds render per row from
340
337
  // `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
@@ -356,13 +353,14 @@ export function useExpandedTimelineElements(): TimelineElement[] {
356
353
  const clipParentMap = usePlayerStore((s) => s.clipParentMap);
357
354
  const domClipChildren = usePlayerStore((s) => s.domClipChildren);
358
355
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
359
- const isPlaying = usePlayerStore((s) => s.isPlaying);
360
356
  const currentTime = usePlayerStore((s) => s.currentTime);
361
357
 
362
- // Resolve which raw clip drives expansion. This reads currentTime (for paused
363
- // auto-expand) so it re-runs each scrub tick, but it's a cheap manifest scan and
364
- // its RESULT only changes when the playhead crosses a composition boundary. Keying
365
- // the expensive build below on these ids (not raw currentTime) avoids re-allocating
358
+ // Resolve which raw clip drives expansion from the store's committed playhead
359
+ // time. The RAF loop keeps live time out of React and Zustand during playback,
360
+ // so this target deliberately stays stable until the loop commits or a seek /
361
+ // pause updates the store. The scan re-runs on scrub ticks, but its RESULT only
362
+ // changes when the playhead crosses a composition boundary. Keying the expensive
363
+ // build below on these ids (not raw currentTime) avoids re-allocating
366
364
  // expandedElements — and cascading TimelineClip re-renders — on every tick.
367
365
  const { rawId, selectedRawId } = useMemo(() => {
368
366
  if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
@@ -371,14 +369,13 @@ export function useExpandedTimelineElements(): TimelineElement[] {
371
369
  return {
372
370
  rawId: resolveTimelineExpansionRawId({
373
371
  selectedElementId,
374
- isPlaying,
375
372
  currentTime,
376
373
  manifest: clipManifest,
377
374
  parentMap: clipParentMap,
378
375
  }),
379
376
  selectedRawId: resolveRawId(selectedElementId, clipManifest, clipParentMap),
380
377
  };
381
- }, [clipManifest, clipParentMap, selectedElementId, isPlaying, currentTime]);
378
+ }, [clipManifest, clipParentMap, selectedElementId, currentTime]);
382
379
 
383
380
  return useMemo(() => {
384
381
  if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {