@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/dist/assets/{hyperframes-player-70BfjZRz.js → hyperframes-player-DdNgW-Np.js} +1 -1
- package/dist/assets/{index-RQ7S6nvA.js → index-Ci7Uy8fR.js} +1 -1
- package/dist/assets/{index-KH4Y5JCd.js → index-Cy6fGN1u.js} +6 -6
- package/dist/assets/{index-DEnjB744.js → index-bv8b5eiQ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +13 -11
- 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/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
- package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/sdk": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
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
|
-
/**
|
|
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) {
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
).
|
|
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
|
|
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 (
|
|
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
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// the
|
|
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,
|
|
378
|
+
}, [clipManifest, clipParentMap, selectedElementId, currentTime]);
|
|
382
379
|
|
|
383
380
|
return useMemo(() => {
|
|
384
381
|
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|