@hyperframes/studio 0.8.61 → 0.8.63

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 (39) hide show
  1. package/dist/assets/{hyperframes-player-nmVkPQza.js → hyperframes-player-FmXtbvEE.js} +1 -1
  2. package/dist/assets/{index-DS0kLbuM.css → index-BnsYeuxA.css} +1 -1
  3. package/dist/assets/{index-COG0K-CQ.js → index-CISQydDK.js} +1 -1
  4. package/dist/assets/{index-BK36b0XY.js → index-CjueZL58.js} +176 -176
  5. package/dist/assets/{index-2-HsQB2C.js → index-DCqmNhmi.js} +1 -1
  6. package/dist/index.d.ts +44 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4977 -5001
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/clipContentExports.test.tsx +54 -0
  13. package/src/components/nle/TimelinePane.tsx +8 -185
  14. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +0 -49
  15. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +4 -15
  16. package/src/components/ui/VideoFrameThumbnail.test.tsx +170 -0
  17. package/src/components/ui/VideoFrameThumbnail.tsx +10 -5
  18. package/src/hooks/useRenderClipContent.ts +1 -1
  19. package/src/index.ts +10 -0
  20. package/src/player/components/AudioWaveform.tsx +1 -1
  21. package/src/player/components/CompositionThumbnail.test.ts +7 -0
  22. package/src/player/components/CompositionThumbnail.tsx +7 -3
  23. package/src/player/components/ImageThumbnail.tsx +1 -1
  24. package/src/player/components/TimelineClipJoins.test.ts +27 -0
  25. package/src/player/components/TimelineClipJoins.tsx +52 -0
  26. package/src/player/components/TimelineLanes.test.tsx +35 -0
  27. package/src/player/components/TimelineLanes.tsx +4 -5
  28. package/src/player/components/TimelineTransitionOverlays.tsx +36 -13
  29. package/src/player/components/timelineDragLanding.ts +1 -5
  30. package/src/player/components/timelineGaps.test.ts +3 -33
  31. package/src/player/components/timelineGaps.ts +4 -11
  32. package/src/player/components/timelineZones.test.ts +0 -5
  33. package/src/player/components/timelineZones.ts +2 -3
  34. package/src/player/hooks/useShadowPreviewReload.ts +41 -6
  35. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
  36. package/src/styles/theme.css +3 -0
  37. package/src/telemetry/events.test.ts +0 -6
  38. package/src/telemetry/events.ts +0 -8
  39. package/src/components/nle/TimelinePane.test.ts +0 -63
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useMemo, useRef, useState } from "react";
1
+ import { memo, useCallback, useMemo, useRef, useState, type CSSProperties } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
@@ -153,7 +153,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
153
153
  {value && (
154
154
  <div
155
155
  className="absolute inset-0 flex"
156
- style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
156
+ style={{
157
+ animation: "hf-thumb-fade 200ms ease-out",
158
+ mixBlendMode:
159
+ "var(--timeline-composition-thumbnail-blend)" as CSSProperties["mixBlendMode"],
160
+ }}
157
161
  >
158
162
  {Array.from({ length: frameCount }, (_, index) => (
159
163
  <div
@@ -166,7 +170,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
166
170
  alt=""
167
171
  draggable={false}
168
172
  className="absolute inset-0 h-full w-full object-cover"
169
- style={{ opacity: 0.7 }}
173
+ style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
170
174
  />
171
175
  </div>
172
176
  ))}
@@ -5,7 +5,7 @@ import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailSche
5
5
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
6
6
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
7
7
 
8
- interface ImageThumbnailProps {
8
+ export interface ImageThumbnailProps {
9
9
  imageSrc: string;
10
10
  label: string;
11
11
  labelColor: string;
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveTimelineClipJoins } from "./TimelineClipJoins";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+
5
+ const clip = (id: string, start: number, duration: number): TimelineElement => ({
6
+ id,
7
+ tag: "video",
8
+ start,
9
+ duration,
10
+ track: 0,
11
+ });
12
+
13
+ describe("deriveTimelineClipJoins", () => {
14
+ it("finds joins in time order, whatever order the clips arrive in", () => {
15
+ expect(
16
+ deriveTimelineClipJoins([clip("c", 3, 1), clip("a", 0, 1.5), clip("b", 1.5, 1.5)]),
17
+ ).toEqual([1.5, 3]);
18
+ });
19
+
20
+ it("treats float noise as a join and a one-frame gap or an overlap as none", () => {
21
+ expect(deriveTimelineClipJoins([clip("a", 0, 0.1 + 0.2), clip("b", 0.3, 1)])).toEqual([
22
+ 0.1 + 0.2,
23
+ ]);
24
+ expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 1 + 1 / 60, 1)])).toEqual([]);
25
+ expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 0.8, 1)])).toEqual([]);
26
+ });
27
+ });
@@ -0,0 +1,52 @@
1
+ import { CLIP_Y } from "./timelineLayout";
2
+ import { sortedLaneClips, TRACK_GAP_EPSILON_S } from "./timelineGaps";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+
5
+ /** Times where one clip on a row ends exactly where the next begins. */
6
+ export function deriveTimelineClipJoins(elements: readonly TimelineElement[]): number[] {
7
+ const sorted = sortedLaneClips(elements);
8
+ const joins: number[] = [];
9
+ for (let index = 1; index < sorted.length; index += 1) {
10
+ const previous = sorted[index - 1]!;
11
+ const end = previous.start + previous.duration;
12
+ if (Math.abs(sorted[index]!.start - end) < TRACK_GAP_EPSILON_S) joins.push(end);
13
+ }
14
+ return joins;
15
+ }
16
+
17
+ interface TimelineClipJoinsProps {
18
+ elements: readonly TimelineElement[];
19
+ pixelsPerSecond: number;
20
+ rowHeight: number;
21
+ clipBarHeight?: number;
22
+ color: string;
23
+ }
24
+
25
+ /** A hairline in the row colour over each join, so touching clips read as two without moving either. */
26
+ export function TimelineClipJoins({
27
+ elements,
28
+ pixelsPerSecond,
29
+ rowHeight,
30
+ clipBarHeight,
31
+ color,
32
+ }: TimelineClipJoinsProps) {
33
+ const height = clipBarHeight ?? rowHeight - 2 * CLIP_Y;
34
+ return deriveTimelineClipJoins(elements).map((time) => (
35
+ <div
36
+ key={time}
37
+ data-timeline-clip-join=""
38
+ aria-hidden="true"
39
+ className="absolute pointer-events-none"
40
+ style={{
41
+ left: time * pixelsPerSecond,
42
+ top: CLIP_Y,
43
+ width: 1,
44
+ height,
45
+ transform: "translateX(-50%)",
46
+ background: color,
47
+ // Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
48
+ zIndex: 5,
49
+ }}
50
+ />
51
+ ));
52
+ }
@@ -462,3 +462,38 @@ describe("TimelineLanes clip thumbnails", () => {
462
462
  act(() => view.root.unmount());
463
463
  });
464
464
  });
465
+
466
+ describe("TimelineLanes clip joins", () => {
467
+ const at = (id: string, start: number, duration: number): TimelineElement => ({
468
+ ...element(id, TRACK_A),
469
+ start,
470
+ duration,
471
+ });
472
+
473
+ it("draws one row-coloured hairline where clips touch, and leaves the clips where they are", () => {
474
+ const view = renderLanes({
475
+ elements: [at("clip-a", 0, 2), at("clip-b", 2, 1.5), at("clip-c", 4, 1)],
476
+ });
477
+
478
+ const joins = view.host.querySelectorAll<HTMLElement>("[data-timeline-clip-join]");
479
+ expect(joins).toHaveLength(1);
480
+ expect(joins[0]?.style.left).toBe("200px");
481
+ expect(joins[0]?.style.width).toBe("1px");
482
+ expect(joins[0]?.style.background).toBe(defaultTimelineTheme.rowBackground);
483
+ const clipB = view.host.querySelector<HTMLElement>('[data-el-id="clip-b"]');
484
+ expect(clipB?.style.left).toBe("200px");
485
+ expect(clipB?.style.width).toBe("150px");
486
+ act(() => view.root.unmount());
487
+ });
488
+
489
+ it("draws no join while a clip is being moved, since the moved clip is drawn elsewhere", () => {
490
+ const clipA = at("clip-a", 0, 2);
491
+ const view = renderLanes({
492
+ elements: [clipA, at("clip-b", 2, 1.5)],
493
+ draggedClip: { element: clipA, started: true } as DraggedClipState,
494
+ });
495
+
496
+ expect(view.host.querySelectorAll("[data-timeline-clip-join]")).toHaveLength(0);
497
+ act(() => view.root.unmount());
498
+ });
499
+ });
@@ -32,7 +32,6 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
32
32
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
33
33
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
34
34
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
35
- import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
36
35
  import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
37
36
 
38
37
  export function TimelineLanes({
@@ -224,9 +223,6 @@ export function TimelineLanes({
224
223
  // right only while it is collapsed and the row is nothing but bar.
225
224
  const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
226
225
  const automationElements = els.map(getPreviewElement);
227
- const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
228
- (seam) => seam.incoming.track === trackNum,
229
- );
230
226
  // Minted here because this is the only place that sees BOTH ends of
231
227
  // the disclosure: the caret in the sticky header and the diamond lanes
232
228
  // on the canvas. Keyed by display row, not by `trackNum`, which is a
@@ -555,7 +551,10 @@ export function TimelineLanes({
555
551
  })
556
552
  }
557
553
  <TimelineTransitionOverlays
558
- seams={transitionSeams}
554
+ transitionElements={allTransitionElements}
555
+ track={trackNum}
556
+ rowElements={draggedClip?.started ? [] : automationElements}
557
+ rowBackground={rowBackground}
559
558
  pixelsPerSecond={pps}
560
559
  rowHeight={rowHeight}
561
560
  clipBarHeight={clipBarHeight}
@@ -1,30 +1,53 @@
1
1
  import { CLIP_Y } from "./timelineLayout";
2
2
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
- import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+ import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
4
5
  import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
6
+ import { TimelineClipJoins } from "./TimelineClipJoins";
5
7
 
6
8
  interface TimelineTransitionOverlaysProps {
7
- seams: readonly TimelineTransitionSeam[];
9
+ transitionElements: readonly TimelineElement[];
10
+ track: number;
11
+ rowElements: readonly TimelineElement[];
12
+ rowBackground: string;
8
13
  pixelsPerSecond: number;
9
14
  rowHeight: number;
10
15
  clipBarHeight?: number;
11
16
  }
12
17
 
18
+ /** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
13
19
  export function TimelineTransitionOverlays({
14
- seams,
20
+ transitionElements,
21
+ track,
22
+ rowElements,
23
+ rowBackground,
15
24
  pixelsPerSecond,
16
25
  rowHeight,
17
26
  clipBarHeight,
18
27
  }: TimelineTransitionOverlaysProps) {
19
28
  const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
20
- return seams.map((seam) => (
21
- <TimelineTransitionBadge
22
- key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
23
- centerPx={seam.centerTime * pixelsPerSecond}
24
- top={top}
25
- widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
26
- outgoingSrc={seam.outgoing.src}
27
- incomingSrc={seam.incoming.src}
28
- />
29
- ));
29
+ const seams = deriveTimelineTransitionSeams(transitionElements).filter(
30
+ (seam) => seam.incoming.track === track,
31
+ );
32
+ return (
33
+ <>
34
+ <TimelineClipJoins
35
+ elements={rowElements}
36
+ pixelsPerSecond={pixelsPerSecond}
37
+ rowHeight={rowHeight}
38
+ clipBarHeight={clipBarHeight}
39
+ color={rowBackground}
40
+ />
41
+ {seams.map((seam) => (
42
+ <TimelineTransitionBadge
43
+ key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
44
+ centerPx={seam.centerTime * pixelsPerSecond}
45
+ top={top}
46
+ widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
47
+ outgoingSrc={seam.outgoing.src}
48
+ incomingSrc={seam.incoming.src}
49
+ />
50
+ ))}
51
+ </>
52
+ );
30
53
  }
@@ -37,15 +37,11 @@ export function layoutAfterTrackInsert(
37
37
  } | null {
38
38
  const { elements, trackOrder } = deps;
39
39
  const editKey = keyOf(element);
40
- // Expanded-child rows are synthetic host lanes, not source-file topology.
41
- if (element.expandedParentStart != null) return null;
42
40
  const targetTrack = insertTrackValue(trackOrder, insertRow);
43
41
  // Foreign display rows and the opposite zone must not affect this topology.
44
42
  const writableZone = classifyZone(element);
45
43
  const writable = (src: TimelineElement): boolean =>
46
- sameSourceFile(src, element) &&
47
- classifyZone(src) === writableZone &&
48
- src.expandedParentStart == null;
44
+ sameSourceFile(src, element) && classifyZone(src) === writableZone;
49
45
  const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
50
46
  (a, b) => a - b,
51
47
  );
@@ -183,39 +183,9 @@ describe("trackHasGaps", () => {
183
183
  });
184
184
  });
185
185
 
186
- describe("lane floor (expanded sub-comp children)", () => {
187
- const child = (id: string, start: number, duration: number): TimelineElement => ({
188
- ...el(id, start, duration),
189
- expandedParentStart: 16,
190
- sourceFile: "scene.html",
191
- });
192
-
193
- it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
186
+ describe("lane floor", () => {
187
+ it("laneGapFloor is always 0", () => {
194
188
  expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
195
- expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16);
196
- });
197
-
198
- it("compaction lands the first child at the HOST window start, never absolute 0", () => {
199
- const lane = [child("c1", 18, 2), child("c2", 22, 2)];
200
- expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
201
- { key: "c1", newStart: 16 },
202
- { key: "c2", newStart: 18 },
203
- ]);
204
- });
205
-
206
- it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
207
- const lane = [child("c1", 18, 2)];
208
- const floor = laneGapFloor(lane);
209
- expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
210
- gapStart: 16,
211
- gapEnd: 18,
212
- followingKeys: ["c1"],
213
- });
214
- expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
215
- });
216
-
217
- it("a child lane contiguous from its host start has no gaps", () => {
218
- const lane = [child("c1", 16, 2), child("c2", 18, 2)];
219
- expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
189
+ expect(laneGapFloor([])).toBe(0);
220
190
  });
221
191
  });
@@ -17,22 +17,15 @@ export const TRACK_GAP_EPSILON_S = 1e-3;
17
17
 
18
18
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
19
19
 
20
- /**
21
- * The lane's time ORIGIN — the earliest start a clip on this lane may take.
22
- * 0 for ordinary lanes; for a lane of expanded sub-comp children (post-
23
- * collision-fix a lane is always single-origin) it is the children's host
24
- * window start (`expandedParentStart`): display times are host-absolute, so
25
- * compacting toward absolute 0 would drag a child BEFORE its host's window
26
- * and persist a wrong (even negative) local time.
27
- */
28
- export function laneGapFloor(elements: readonly TimelineElement[]): number {
29
- return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
20
+ /** The lane's time ORIGIN — the earliest start a clip on this lane may take. */
21
+ export function laneGapFloor(_elements: readonly TimelineElement[]): number {
22
+ return 0;
30
23
  }
31
24
  export const round3 = (v: number) => Math.round(v * 1000) / 1000;
32
25
  const endOf = (e: TimelineElement) => e.start + e.duration;
33
26
 
34
27
  /** Lane clips sorted by start (key as a deterministic tie-break). */
35
- function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
28
+ export function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
36
29
  return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
37
30
  }
38
31
 
@@ -67,11 +67,6 @@ describe("isMainTrackElement", () => {
67
67
  it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => {
68
68
  expect(isMainTrackElement(el("m", "audio", 0))).toBe(false);
69
69
  });
70
-
71
- it("is false for an inline-expanded sub-composition child on track 0", () => {
72
- const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 };
73
- expect(isMainTrackElement(child)).toBe(false);
74
- });
75
70
  });
76
71
 
77
72
  describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
@@ -16,10 +16,9 @@ export function classifyZone(el: TimelineElement): TrackZone {
16
16
  }
17
17
 
18
18
  /** The "main track" is a convention, not a schema field: the first
19
- * visual-zone display lane, matched only when it actually holds a visual
20
- * clip and isn't an inline-expanded sub-composition child. */
19
+ * visual-zone display lane, matched only when it actually holds a visual clip. */
21
20
  export function isMainTrackElement(el: TimelineElement): boolean {
22
- return el.track === 0 && classifyZone(el) === "visual" && el.expandedParentStart == null;
21
+ return el.track === 0 && classifyZone(el) === "visual";
23
22
  }
24
23
 
25
24
  const keyOf = (el: TimelineElement) => el.key ?? el.id;
@@ -16,8 +16,13 @@ import type { PlaybackAdapter } from "../lib/playbackTypes";
16
16
 
17
17
  // The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
18
18
  // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
19
+ // It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
19
20
  export const SHADOW_READY_TIMEOUT_MS = 15_000;
20
21
 
22
+ function isDocumentHidden(): boolean {
23
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
24
+ }
25
+
21
26
  type UseShadowPreviewReloadParams = Omit<
22
27
  UseTimelineSyncCallbacksParams,
23
28
  "probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
@@ -55,11 +60,14 @@ export function useShadowPreviewReload({
55
60
  const onReloadFailedRef = useRef(onReloadFailed);
56
61
  onReloadFailedRef.current = onReloadFailed;
57
62
  const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
63
+ // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
64
+ const budgetGenRef = useRef<number | null>(null);
58
65
  const cancelPendingLoadRef = useRef<() => void>(() => {});
59
66
  const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
60
67
 
61
68
  const stopPendingShadow = useCallback(() => {
62
69
  clearTimeout(readyTimerRef.current);
70
+ budgetGenRef.current = null;
63
71
  cancelPendingLoadRef.current();
64
72
  pendingCommitRef.current = null;
65
73
  visuallyReadyGenRef.current = null;
@@ -145,18 +153,30 @@ export function useShadowPreviewReload({
145
153
  shadowIframeRef.current = node;
146
154
  }, []);
147
155
 
156
+ const armReadyTimer = useCallback(
157
+ (gen: number) => {
158
+ clearTimeout(readyTimerRef.current);
159
+ if (isDocumentHidden()) return;
160
+ readyTimerRef.current = setTimeout(
161
+ () => failShadow(gen, "it took too long to load"),
162
+ SHADOW_READY_TIMEOUT_MS,
163
+ );
164
+ },
165
+ [failShadow],
166
+ );
167
+ const armReadyTimerRef = useRef(armReadyTimer);
168
+ armReadyTimerRef.current = armReadyTimer;
169
+
148
170
  const beginShadowReload = useCallback(
149
171
  (url: string) => {
150
172
  shadowGenRef.current += 1;
151
173
  const gen = shadowGenRef.current;
152
174
  stopPendingShadow();
153
- readyTimerRef.current = setTimeout(
154
- () => failShadow(gen, "it took too long to load"),
155
- SHADOW_READY_TIMEOUT_MS,
156
- );
175
+ budgetGenRef.current = gen;
176
+ armReadyTimer(gen);
157
177
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
158
178
  },
159
- [stopPendingShadow, failShadow],
179
+ [stopPendingShadow, armReadyTimer],
160
180
  );
161
181
 
162
182
  // Composition switch (not an edit reload): drop any in-flight shadow.
@@ -169,7 +189,22 @@ export function useShadowPreviewReload({
169
189
  setPreviewSlots(planShadowDiscard);
170
190
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
171
191
 
172
- useMountEffect(() => stopPendingShadow);
192
+ // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
193
+ useMountEffect(() => {
194
+ const onVisibilityChange = () => {
195
+ if (isDocumentHidden()) {
196
+ clearTimeout(readyTimerRef.current);
197
+ return;
198
+ }
199
+ const gen = budgetGenRef.current;
200
+ if (gen != null && gen === shadowGenRef.current) armReadyTimerRef.current(gen);
201
+ };
202
+ document.addEventListener("visibilitychange", onVisibilityChange);
203
+ return () => {
204
+ document.removeEventListener("visibilitychange", onVisibilityChange);
205
+ stopPendingShadow();
206
+ };
207
+ });
173
208
 
174
209
  return {
175
210
  previewSlots,
@@ -289,6 +289,50 @@ describe("shadow reload readiness and failure", () => {
289
289
  unmount(root);
290
290
  });
291
291
 
292
+ it("does not spend the budget while the tab is hidden, and promotes once it is visible", () => {
293
+ vi.useFakeTimers();
294
+ const onPreviewReloadFailed = vi.fn();
295
+ const setVisibility = stubVisibility("hidden");
296
+ const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
297
+ const shadow = makeShadowWithSpies();
298
+ act(() => {
299
+ getApi().setShadowIframeNode(shadow.iframe);
300
+ getApi().onShadowIframeLoad(gen);
301
+ });
302
+
303
+ // A hidden tab renders no frames, so readiness cannot arrive; the wall clock must not fail it.
304
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS * 3));
305
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
306
+ expect(getApi().previewSlots).toHaveLength(2);
307
+
308
+ act(() => setVisibility("visible"));
309
+ act(() => getApi().onShadowReadyChange(gen, true));
310
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
311
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
312
+ unmount(root);
313
+ });
314
+
315
+ it("restarts the full budget when a hidden tab becomes visible, then fails a shadow that stays silent", () => {
316
+ vi.useFakeTimers();
317
+ vi.spyOn(console, "error").mockImplementation(() => {});
318
+ const onPreviewReloadFailed = vi.fn();
319
+ const setVisibility = stubVisibility("visible");
320
+ const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
321
+
322
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1000));
323
+ act(() => setVisibility("hidden"));
324
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
325
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
326
+
327
+ act(() => setVisibility("visible"));
328
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1));
329
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
330
+ act(() => void vi.advanceTimersByTime(1));
331
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
332
+ expect(getApi().iframeRef.current).toBe(live);
333
+ unmount(root);
334
+ });
335
+
292
336
  it("drops a shadow whose document reports an error and reports the cause", () => {
293
337
  vi.spyOn(console, "error").mockImplementation(() => {});
294
338
  const onPreviewReloadFailed = vi.fn();
@@ -455,3 +499,12 @@ function unmount(root: ReturnType<typeof createRoot>) {
455
499
  root.unmount();
456
500
  });
457
501
  }
502
+
503
+ function stubVisibility(initial: DocumentVisibilityState) {
504
+ let state = initial;
505
+ vi.spyOn(document, "visibilityState", "get").mockImplementation(() => state);
506
+ return (next: DocumentVisibilityState) => {
507
+ state = next;
508
+ document.dispatchEvent(new Event("visibilitychange"));
509
+ };
510
+ }
@@ -80,6 +80,9 @@
80
80
  --timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
81
81
  --timeline-clip-chip-text: #ffffff;
82
82
  --timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
83
+ /* Composition and text clips draw their frame as it is, like video filmstrips; a host may dim them. */
84
+ --timeline-composition-thumbnail-opacity: 1;
85
+ --timeline-composition-thumbnail-blend: normal;
83
86
  --timeline-thumbnail-shimmer: linear-gradient(
84
87
  90deg,
85
88
  rgba(255, 255, 255, 0.02) 0%,
@@ -12,7 +12,6 @@ const {
12
12
  trackPreviewFirstFrame,
13
13
  trackStudioRenderStart,
14
14
  trackStudioRazorSplit,
15
- trackStudioExpandedClipEdit,
16
15
  trackStudioKeyframeLaneExpand,
17
16
  trackStudioSegmentEaseEdit,
18
17
  trackStudioFeedback,
@@ -107,11 +106,6 @@ describe("studio telemetry events", () => {
107
106
  expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
108
107
  });
109
108
 
110
- it("trackStudioExpandedClipEdit emits 'studio_expanded_clip_edit' with action", () => {
111
- trackStudioExpandedClipEdit({ action: "resize" });
112
- expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
113
- });
114
-
115
109
  it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => {
116
110
  trackStudioKeyframeLaneExpand({ expanded: true });
117
111
  expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true });
@@ -84,14 +84,6 @@ export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: nu
84
84
  });
85
85
  }
86
86
 
87
- // Adoption signal for the inline timeline-expansion surface: edits applied to a
88
- // sub-composition child clip while its parent scene is expanded.
89
- export function trackStudioExpandedClipEdit(props: {
90
- action: "move" | "resize" | "delete" | "split";
91
- }): void {
92
- trackEvent("studio_expanded_clip_edit", { action: props.action });
93
- }
94
-
95
87
  // Adoption signal for the per-clip keyframe-lane caret toggle.
96
88
  export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void {
97
89
  trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded });
@@ -1,63 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import type { TimelineElement } from "../../player";
3
- import {
4
- forwardRebasedTimelineMoveElements,
5
- forwardRebasedTimelineResizeElements,
6
- } from "./TimelinePane";
7
-
8
- describe("TimelinePane move wrapper", () => {
9
- it("rebases expanded edits and forwards track-insert as the third argument", async () => {
10
- const onMoveElements = vi.fn().mockResolvedValue(undefined);
11
- const element: TimelineElement = {
12
- id: "expanded-a",
13
- domId: "a",
14
- tag: "div",
15
- start: 12,
16
- duration: 2,
17
- track: 0,
18
- expandedParentStart: 10,
19
- };
20
- await forwardRebasedTimelineMoveElements(
21
- [{ element, updates: { start: 14, track: 2 } }],
22
- "clip-lane-move:7",
23
- "track-insert",
24
- onMoveElements,
25
- Number.POSITIVE_INFINITY,
26
- );
27
- expect(onMoveElements).toHaveBeenCalledWith(
28
- [
29
- {
30
- element: expect.objectContaining({ id: "a", start: 2 }),
31
- updates: { start: 4, track: 2 },
32
- },
33
- ],
34
- "clip-lane-move:7",
35
- "track-insert",
36
- // The per-gesture coalesce window rides along with the shared key.
37
- Number.POSITIVE_INFINITY,
38
- );
39
- });
40
-
41
- it("forwards one rebased resize batch with the shared gesture key", async () => {
42
- const onResizeElements = vi.fn().mockResolvedValue(undefined);
43
- const element: TimelineElement = {
44
- id: "expanded-a",
45
- domId: "a",
46
- tag: "div",
47
- start: 12,
48
- duration: 2,
49
- track: 0,
50
- expandedParentStart: 10,
51
- };
52
- await forwardRebasedTimelineResizeElements(
53
- [{ element, start: 13, duration: 3 }],
54
- { coalesceKey: "clip-group-resize:a:b" },
55
- onResizeElements,
56
- );
57
- expect(onResizeElements).toHaveBeenCalledTimes(1);
58
- expect(onResizeElements).toHaveBeenCalledWith(
59
- [{ element: expect.objectContaining({ id: "a", start: 2 }), start: 3, duration: 3 }],
60
- { coalesceKey: "clip-group-resize:a:b" },
61
- );
62
- });
63
- });