@hyperframes/studio 0.8.104 → 0.8.106

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 (225) hide show
  1. package/dist/assets/{hyperframes-player-DAoaajuM.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/index-CMiYVGv5.js +399 -0
  3. package/dist/assets/{index-WV9LroAD.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-BDK4rTMb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-HIGHVWYD.js → chunk-ICTZJPQQ.js} +3 -2
  9. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  10. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  11. package/dist/chunk-NCUBAVGK.js.map +1 -0
  12. package/dist/index.css +6 -1
  13. package/dist/index.css.map +1 -1
  14. package/dist/index.d.ts +15 -4
  15. package/dist/index.html +11 -2
  16. package/dist/index.js +10391 -9836
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +2 -2
  19. package/dist/styles/tailwind-preset.js.map +1 -1
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +2 -2
  22. package/package.json +9 -9
  23. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  24. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  25. package/src/captions/components/CaptionTimeline.tsx +3 -3
  26. package/src/captions/keyboard.test.ts +6 -0
  27. package/src/captions/keyboard.ts +2 -1
  28. package/src/components/AskAgentModal.tsx +1 -1
  29. package/src/components/LintModal.tsx +35 -35
  30. package/src/components/StudioErrorBoundary.tsx +1 -1
  31. package/src/components/StudioHeader.dom.test.tsx +35 -13
  32. package/src/components/StudioHeader.tsx +6 -2
  33. package/src/components/StudioToast.tsx +7 -16
  34. package/src/components/ThemeToggle.tsx +99 -0
  35. package/src/components/TimelineToolPicker.tsx +1 -1
  36. package/src/components/TimelineToolbar.tsx +11 -11
  37. package/src/components/dock/Dock.tsx +1 -1
  38. package/src/components/dock/dock.css +9 -1
  39. package/src/components/editor/AnimationCard.tsx +5 -5
  40. package/src/components/editor/AnimationCardParts.tsx +1 -1
  41. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  42. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  43. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  44. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  45. package/src/components/editor/DomEditOverlay.tsx +37 -5
  46. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  47. package/src/components/editor/EaseCurveSection.tsx +14 -19
  48. package/src/components/editor/EaseModeControls.tsx +2 -2
  49. package/src/components/editor/EaseParamFields.tsx +8 -8
  50. package/src/components/editor/FileTree.tsx +1 -1
  51. package/src/components/editor/FileTreeIcons.tsx +47 -26
  52. package/src/components/editor/FileTreeNodes.tsx +11 -8
  53. package/src/components/editor/GestureRecordControl.tsx +3 -3
  54. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  55. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  56. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  57. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  58. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  59. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  60. package/src/components/editor/LayersPanel.tsx +3 -3
  61. package/src/components/editor/PromotableControl.tsx +2 -2
  62. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  63. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  64. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  65. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  66. package/src/components/editor/SnapToolbar.tsx +5 -5
  67. package/src/components/editor/SourceEditor.tsx +2 -6
  68. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  69. package/src/components/editor/Transform3DCube.tsx +1 -1
  70. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  71. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  72. package/src/components/editor/easeCurveSvg.tsx +1 -1
  73. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  74. package/src/components/editor/manualEditsDom.ts +36 -3
  75. package/src/components/editor/manualEditsDomPatches.test.ts +2 -86
  76. package/src/components/editor/manualEditsDomPatches.ts +0 -30
  77. package/src/components/editor/manualEditsSnapshot.ts +1 -0
  78. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  79. package/src/components/editor/marqueeCommit.ts +6 -1
  80. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  81. package/src/components/editor/overlayFrameLoop.ts +21 -0
  82. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  83. package/src/components/editor/plainTranslate.ts +34 -3
  84. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  85. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  86. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  87. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  88. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  89. package/src/components/editor/propertyPanelFill.tsx +6 -6
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  92. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  93. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  94. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  97. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  98. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  99. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  101. package/src/components/editor/propertyPanelFont.tsx +71 -48
  102. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  106. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  107. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  108. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  109. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  110. package/src/components/editor/propertyPanelSections.tsx +2 -2
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  114. package/src/components/editor/studioCodeTheme.ts +99 -0
  115. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  116. package/src/components/editor/useDomEditNudge.ts +2 -2
  117. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  118. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  119. package/src/components/editor/useMotionPathData.ts +5 -1
  120. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  121. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  122. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  123. package/src/components/nle/NLEPreview.test.ts +8 -4
  124. package/src/components/nle/PreviewOverlays.tsx +1 -1
  125. package/src/components/nle/PreviewPane.tsx +3 -3
  126. package/src/components/nle/PreviewPoster.tsx +29 -1
  127. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  128. package/src/components/panels/SlideshowPanel.tsx +4 -4
  129. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  130. package/src/components/panels/VariablesBindElement.tsx +1 -1
  131. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  132. package/src/components/panels/VariablesPanel.tsx +4 -6
  133. package/src/components/panels/VariablesRowAction.tsx +1 -1
  134. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  135. package/src/components/renders/RenderQueue.tsx +2 -2
  136. package/src/components/renders/RenderQueueItem.tsx +3 -3
  137. package/src/components/sidebar/AssetCard.tsx +3 -3
  138. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  139. package/src/components/sidebar/AssetsTab.tsx +6 -6
  140. package/src/components/sidebar/AudioRow.tsx +5 -3
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  143. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  144. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  145. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  148. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  149. package/src/components/timelineToolbarStyles.ts +3 -3
  150. package/src/components/ui/Button.tsx +4 -4
  151. package/src/components/ui/HyperframesLogo.tsx +19 -18
  152. package/src/components/ui/Input.tsx +1 -1
  153. package/src/components/ui/Menu.tsx +4 -2
  154. package/src/hooks/appHotkeysDispatch.ts +2 -1
  155. package/src/hooks/elementOffsetStager.ts +8 -7
  156. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  157. package/src/hooks/useAppHotkeys.test.ts +32 -0
  158. package/src/hooks/useAppHotkeys.ts +1 -2
  159. package/src/hooks/useClipboard.source.test.ts +141 -23
  160. package/src/hooks/useClipboard.ts +154 -123
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.test.tsx +76 -72
  163. package/src/hooks/useDomEditSession.ts +7 -43
  164. package/src/hooks/useDomGeometryCommit.test.tsx +4 -1
  165. package/src/hooks/useDomGeometryCommit.ts +0 -1
  166. package/src/hooks/useDomGeometryCommits.test.tsx +177 -32
  167. package/src/hooks/useDomGeometryCommits.ts +4 -49
  168. package/src/hooks/useGsapTweenCache.ts +1 -2
  169. package/src/hooks/useInlineTextEdit.ts +16 -2
  170. package/src/hooks/useLivePreviewIframe.ts +18 -0
  171. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  172. package/src/hooks/usePreviewPersistence.ts +2 -5
  173. package/src/hooks/useRenderClipContent.ts +3 -3
  174. package/src/index.ts +2 -0
  175. package/src/livePreviewIframeExport.test.tsx +33 -0
  176. package/src/main.tsx +4 -1
  177. package/src/player/components/AudioWaveform.test.tsx +36 -15
  178. package/src/player/components/AudioWaveform.tsx +24 -7
  179. package/src/player/components/ClipContextMenu.tsx +1 -1
  180. package/src/player/components/CompositionThumbnail.tsx +23 -11
  181. package/src/player/components/EditModal.tsx +6 -6
  182. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  183. package/src/player/components/PlayerControls.tsx +3 -3
  184. package/src/player/components/ShortcutsPanel.tsx +8 -20
  185. package/src/player/components/TimelineCanvas.tsx +1 -1
  186. package/src/player/components/TimelineClip.test.tsx +38 -0
  187. package/src/player/components/TimelineClip.tsx +16 -8
  188. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  189. package/src/player/components/TimelineClipFades.tsx +368 -137
  190. package/src/player/components/TimelineEmptyState.tsx +2 -2
  191. package/src/player/components/TimelineLanes.tsx +0 -1
  192. package/src/player/components/TrackClipCount.tsx +1 -1
  193. package/src/player/components/VolumeControl.tsx +1 -1
  194. package/src/player/components/timelineClipChildren.tsx +1 -1
  195. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  196. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  197. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  198. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  199. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  200. package/src/player/lib/automationStoreSync.test.ts +18 -1
  201. package/src/player/lib/automationStoreSync.ts +29 -17
  202. package/src/player/lib/thumbnailScheduler.ts +4 -5
  203. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  204. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  205. package/src/player/sceneSwap.ts +18 -0
  206. package/src/styles/components.css +26 -0
  207. package/src/styles/hex-baseline.json +7 -19
  208. package/src/styles/studio.css +4 -7
  209. package/src/styles/tailwind-preset.shared.js +2 -1
  210. package/src/styles/theme.css +214 -147
  211. package/src/styles/theme.test.ts +13 -9
  212. package/src/utils/blockCategories.ts +41 -9
  213. package/src/utils/clipboardPayload.ts +12 -1
  214. package/src/utils/studioPendingEdits.ts +21 -0
  215. package/src/utils/studioTheme.test.tsx +141 -0
  216. package/src/utils/studioTheme.ts +34 -0
  217. package/src/utils/studioUiPreferences.ts +3 -0
  218. package/src/utils/typingTarget.test.ts +8 -1
  219. package/src/utils/typingTarget.ts +5 -0
  220. package/dist/assets/index-CQN3M56N.css +0 -1
  221. package/dist/assets/index-De2aO4XM.js +0 -399
  222. package/dist/chunk-62DDWOSH.js.map +0 -1
  223. package/dist/chunk-6GAO75IU.js.map +0 -1
  224. package/dist/chunk-HIGHVWYD.js.map +0 -1
  225. package/src/hooks/gsapTargetCache.ts +0 -62
@@ -63,13 +63,13 @@ export function TimelineEmptyState({
63
63
  strokeWidth="1.5"
64
64
  strokeLinecap="round"
65
65
  strokeLinejoin="round"
66
- className="text-studio-accent shrink-0"
66
+ className="text-accent-ink shrink-0"
67
67
  >
68
68
  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
69
69
  <polyline points="7 10 12 15 17 10" />
70
70
  <line x1="12" y1="15" x2="12" y2="3" />
71
71
  </svg>
72
- <span className="text-[13px] text-studio-accent">Drop media files to import</span>
72
+ <span className="text-[13px] text-accent-ink">Drop media files to import</span>
73
73
  </>
74
74
  ) : (
75
75
  <>
@@ -353,7 +353,6 @@ export function TimelineLanes({
353
353
  fontSize: 11,
354
354
  letterSpacing: "0.06em",
355
355
  textTransform: "uppercase",
356
- opacity: 0.5,
357
356
  }}
358
357
  >
359
358
  New track
@@ -9,7 +9,7 @@ export function TrackClipCount({ clipCount }: { clipCount: number }) {
9
9
  <span
10
10
  aria-label={`${clipCount} clips`}
11
11
  title={`${clipCount} clips`}
12
- className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
12
+ className="shrink-0 rounded-full bg-hover px-1 text-[9px] leading-[14px] tabular-nums text-text-muted"
13
13
  >
14
14
  {clipCount}
15
15
  </span>
@@ -93,7 +93,7 @@ export const VolumeControl = memo(function VolumeControl({
93
93
  aria-label={muteLabel}
94
94
  aria-pressed={silent}
95
95
  className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
96
- silent ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
96
+ silent ? "text-accent-ink" : "text-neutral-500 hover:text-neutral-200"
97
97
  }`}
98
98
  >
99
99
  <VolumeIcon muted={audioMuted} volume={audioVolume} />
@@ -22,7 +22,7 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
22
22
  if (!lint || lint.count === 0) return null;
23
23
  return (
24
24
  <span
25
- className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
25
+ className="absolute w-1.5 h-1.5 rounded-full bg-warning-ink"
26
26
  style={{ top: 7, right: 7 }}
27
27
  title={lint.messages.join("\n")}
28
28
  />
@@ -39,18 +39,15 @@ function themeTokenValue(token: string): string {
39
39
 
40
40
  describe("timeline motion styles", () => {
41
41
  it.each([
42
- ["--timeline-track-label", "rgba(255, 255, 255, 0.5)"],
43
- ["--timeline-tick-text", "rgba(255, 255, 255, 0.34)"],
44
- ["--timeline-border-strong", "rgba(255, 255, 255, 0.2)"],
45
- ["--timeline-group-member-tint", "rgba(255, 255, 255, 0.035)"],
46
- ["--timeline-overlay-text", "rgba(255, 255, 255, 0.8)"],
47
- ["--timeline-clip-shadow-dragging", "rgba(0, 0, 0, 0.4)"],
48
- ["--timeline-playhead-glow", "rgba(60, 230, 172, 0.14)"],
49
- ["--timeline-playhead-shadow", "rgba(0, 0, 0, 0.55)"],
42
+ ["--timeline-track-label", "var(--color-text-muted)"],
43
+ ["--timeline-tick-text", "var(--color-text-2)"],
44
+ ["--timeline-border-strong", "color-mix(in oklab, var(--color-text-0) 20%, transparent)"],
45
+ ["--timeline-group-member-tint", "color-mix(in oklab, var(--color-text-0) 3.5%, transparent)"],
46
+ ["--timeline-overlay-text", "var(--color-text-2)"],
47
+ ["--timeline-playhead-glow", "color-mix(in oklab, var(--color-accent) 14%, transparent)"],
50
48
  ])("keeps the migrated default for %s", (token, expected) => {
51
49
  expect(themeTokenValue(token)).toBe(expected);
52
50
  });
53
-
54
51
  it("keeps clip motion reduced-motion gated and layout safe", () => {
55
52
  const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
56
53
  expect(mediaStart).toBeGreaterThanOrEqual(0);
@@ -93,13 +90,7 @@ describe("timeline motion styles", () => {
93
90
  expect(timelineClipLabelRule).toContain("text-overflow: ellipsis");
94
91
  expect(timelineClipLabelRule).toContain("max-width: calc(100% - 22px)");
95
92
  expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
96
- expect(themeCss).toContain("--timeline-clip-bg: rgba(255, 255, 255, 0.12)");
97
- expect(themeCss).toContain("--timeline-clip-border: rgba(255, 255, 255, 0.22)");
98
- expect(themeCss).toContain("--timeline-track-label: rgba(255, 255, 255, 0.5)");
99
- expect(themeCss).toContain("--timeline-tick-text: rgba(255, 255, 255, 0.34)");
100
- expect(themeCss).toContain("--timeline-clip-label-active: rgba(232, 255, 247, 0.95)");
101
93
  expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
102
- expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
103
94
  expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
104
95
  expect(themeCss).toContain("--timeline-clip-radius: 8px");
105
96
  expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
@@ -108,18 +99,11 @@ describe("timeline motion styles", () => {
108
99
  expect(themeCss).toContain("--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73)");
109
100
  expect(themeCss).toContain("--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7)");
110
101
  expect(themeCss).toContain("--timeline-clip-chip-text: #ffffff");
111
- expect(themeCss).toContain("--timeline-row-bg: #edf0ed");
112
102
  expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12)");
113
- expect(themeCss).toContain("--timeline-clip-selection: #0f766e");
114
- expect(themeCss).toContain("--timeline-clip-audio-bg: rgba(167, 139, 250, 0.16)");
115
- expect(themeCss).toContain("--timeline-clip-audio-border: rgba(167, 139, 250, 0.4)");
116
- expect(themeCss).toContain("--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24)");
117
- expect(themeCss).toContain("--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96)");
118
103
  expect(audioClipRule).toContain("background-color: var(--timeline-clip-audio-bg)");
119
104
  expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
120
105
  expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
121
106
  expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
122
- expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
123
107
  expect(selectedTimelineClipRule).toContain(
124
108
  "outline: 1.5px solid var(--timeline-clip-selection)",
125
109
  );
@@ -38,8 +38,8 @@ function PropertyGroupNavigation({
38
38
  // target is met with a centered transparent ::before overlay instead of a
39
39
  // bigger box; focus-visible matches every other control in this header.
40
40
  const CHEVRON_BUTTON_CLASS =
41
- "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
42
- "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
41
+ "relative h-5 w-3 border-0 bg-transparent p-0 text-text-2 hover:text-text-0 disabled:text-text-off " +
42
+ "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-ring " +
43
43
  "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
44
44
  "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
45
45
  const seekTo = (keyframe: { percentage: number } | null) => {
@@ -120,7 +120,7 @@ export function PropertyGroupHeaderRow({
120
120
  tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
121
121
  data-property-group={lane.group}
122
122
  data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
123
- className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
123
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-text-2"
124
124
  style={{
125
125
  top: getTimelineLaneTop(laneIndex),
126
126
  // The header column narrows to contentOrigin whenever that is under
@@ -134,12 +134,12 @@ export function PropertyGroupHeaderRow({
134
134
  {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
135
135
  <span className="relative h-full w-3 shrink-0" aria-hidden="true">
136
136
  <span
137
- className="absolute left-1.5 top-0 w-px bg-white/15"
137
+ className="absolute left-1.5 top-0 w-px bg-text-0/15"
138
138
  style={{ height: isLastLane ? "50%" : "100%" }}
139
139
  />
140
- <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
140
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-text-0/15" />
141
141
  </span>
142
- <span className="w-[46px] shrink-0 truncate text-white" title={label}>
142
+ <span className="w-[46px] shrink-0 truncate text-text-0" title={label}>
143
143
  {label}
144
144
  </span>
145
145
  <PropertyGroupNavigation
@@ -154,7 +154,7 @@ export function PropertyGroupHeaderRow({
154
154
  aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
155
155
  title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
156
156
  // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
157
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
157
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-accent-ink focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-ring"
158
158
  onClick={(event) => {
159
159
  // Same as the disclosure caret and the eye: a control in the label
160
160
  // column owns its click, it does not also hit the track row behind it.
@@ -168,7 +168,7 @@ export function PropertyGroupHeaderRow({
168
168
  </button>
169
169
  </PropertyGroupNavigation>
170
170
  <span
171
- className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
171
+ className="min-w-0 flex-1 truncate text-right tabular-nums text-text-muted"
172
172
  title={valueReadout(lane.group, values)}
173
173
  >
174
174
  {valueReadout(lane.group, values)}
@@ -231,7 +231,7 @@ export function AutomationLaneHeaderRow({
231
231
  <div
232
232
  data-automation-lane-label={label}
233
233
  data-timeline-lane-top={top}
234
- className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
234
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-text-2"
235
235
  style={{
236
236
  top,
237
237
  width: columnWidth,
@@ -243,10 +243,10 @@ export function AutomationLaneHeaderRow({
243
243
  tick at the name's own height. */}
244
244
  <span className="relative h-full w-3 shrink-0" aria-hidden="true">
245
245
  <span
246
- className="absolute left-1.5 top-0 w-px bg-white/15"
246
+ className="absolute left-1.5 top-0 w-px bg-text-0/15"
247
247
  style={{ height: isLastLane ? "50%" : "100%" }}
248
248
  />
249
- <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
249
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-text-0/15" />
250
250
  </span>
251
251
  {/* Two lines: what the effect is, then which knob the envelope drives. On
252
252
  one line a band's own name was the first thing truncated in a column this
@@ -262,7 +262,7 @@ export function AutomationLaneHeaderRow({
262
262
  aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
263
263
  title={onReveal ? `Show ${label} in the effect rack` : label}
264
264
  disabled={!onReveal}
265
- className="flex min-w-0 flex-1 flex-col justify-center rounded-sm border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
265
+ className="flex min-w-0 flex-1 flex-col justify-center rounded-sm border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-text-0 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-ring"
266
266
  onPointerDown={(event) => event.stopPropagation()}
267
267
  onClick={(event) => {
268
268
  // The label column owns its click; it does not also fall through to
@@ -271,13 +271,13 @@ export function AutomationLaneHeaderRow({
271
271
  onReveal?.();
272
272
  }}
273
273
  >
274
- <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
274
+ <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-text-2">
275
275
  {name}
276
276
  </span>
277
277
  {param ? (
278
278
  <span
279
279
  data-automation-lane-param=""
280
- className="truncate font-mono text-[9px] text-white/40"
280
+ className="truncate font-mono text-[9px] text-text-muted"
281
281
  >
282
282
  {param}
283
283
  </span>
@@ -285,7 +285,7 @@ export function AutomationLaneHeaderRow({
285
285
  {alsoAutomatedBy ? (
286
286
  <span
287
287
  data-automation-lane-also=""
288
- className="truncate text-[9px] text-[#F5C542]/80"
288
+ className="truncate text-[9px] text-warning-ink"
289
289
  title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
290
290
  >
291
291
  {alsoAutomatedBy} is also fading this.
@@ -311,7 +311,7 @@ export function AutomationLaneHeaderRow({
311
311
  aria-label={`Remove ${label} automation`}
312
312
  title={`Remove ${label} automation`}
313
313
  // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
314
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
314
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-text-muted hover:text-text-0 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-ring"
315
315
  onPointerDown={(event) => event.stopPropagation()}
316
316
  onClick={(event) => {
317
317
  // A control in the label column owns its click; it does not also hit
@@ -22,7 +22,7 @@ function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
22
22
  // Header actions stay native Tab stops because they have no row-level shortcut.
23
23
  // The nearest interactive ancestor wins so their events never masquerade as row events.
24
24
  const target = event.target.closest<HTMLElement>(
25
- "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
25
+ "button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
26
26
  );
27
27
  return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
28
28
  }
@@ -118,3 +118,30 @@ export function attachIframeAdapter(
118
118
  attachIframeWindow(api, win);
119
119
  return adapter;
120
120
  }
121
+
122
+ export function makePreview(body: string, query = ""): HTMLIFrameElement {
123
+ const iframe = makeFakeIframe(makeAdapterWindow().win);
124
+ iframe.src = `http://localhost/api/projects/demo/preview${query}`;
125
+ iframe.contentDocument!.body.innerHTML = body;
126
+ return iframe;
127
+ }
128
+
129
+ export function mountPlayerWithPreview(live: HTMLIFrameElement) {
130
+ const harness = renderTimelinePlayerHarness();
131
+ act(() => {
132
+ harness.getApi().iframeRef.current = live;
133
+ harness.getApi().onIframeLoad();
134
+ });
135
+ return harness;
136
+ }
137
+
138
+ export async function paintShadow(getApi: () => TimelinePlayerApi, shadow: HTMLIFrameElement) {
139
+ const slot = getApi().previewSlots.find((s) => s.role === "shadow");
140
+ if (!slot) throw new Error("no shadow reload is pending");
141
+ act(() => getApi().setShadowIframeNode(shadow));
142
+ await act(async () => {
143
+ getApi().onShadowIframeLoad(slot.gen);
144
+ getApi().onShadowReadyChange(slot.gen, true);
145
+ });
146
+ return slot.gen;
147
+ }
@@ -3,6 +3,11 @@
3
3
 
4
4
  import { useCallback, useRef, useState } from "react";
5
5
  import { useMountEffect } from "../../hooks/useMountEffect";
6
+ import {
7
+ afterStudioManualEditGestures,
8
+ isStudioManualEditGestureLiveIn,
9
+ studioManualEditSavesIn,
10
+ } from "../../components/editor/manualEditsDom";
6
11
  import { logReload } from "../../utils/reloadDebug";
7
12
  import {
8
13
  useTimelineSyncCallbacks,
@@ -14,6 +19,8 @@ import {
14
19
  } from "./useTimelineSyncCallbacks";
15
20
  import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
16
21
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
22
+ import { announcePreviewPromoted } from "../sceneSwap";
23
+ import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
17
24
  import { usePlayerStore } from "../store/playerStore";
18
25
 
19
26
  // One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
@@ -78,12 +85,18 @@ export function useShadowPreviewReload({
78
85
  // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
79
86
  const budgetGenRef = useRef<number | null>(null);
80
87
  const cancelPendingLoadRef = useRef<() => void>(() => {});
88
+ const stopHoldRef = useRef<() => void>(() => {});
89
+ const shadowUrlRef = useRef("");
90
+ const savesAtRequestRef = useRef(0);
91
+ const beginShadowReloadRef = useRef<(url: string) => void>(() => {});
92
+ const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {});
81
93
  const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
82
94
 
83
95
  const stopPendingShadow = useCallback(() => {
84
96
  clearTimeout(readyTimerRef.current);
85
97
  budgetGenRef.current = null;
86
98
  cancelPendingLoadRef.current();
99
+ stopHoldRef.current();
87
100
  pendingCommitRef.current = null;
88
101
  visuallyReadyGenRef.current = null;
89
102
  }, []);
@@ -106,12 +119,58 @@ export function useShadowPreviewReload({
106
119
  [stopPendingShadow, isRefreshingRef, pendingSeekRef],
107
120
  );
108
121
 
122
+ const gestureSavesOnScreen = useCallback(() => {
123
+ const liveDoc = iframeRef.current?.contentDocument;
124
+ return liveDoc ? studioManualEditSavesIn(liveDoc) : 0;
125
+ }, [iframeRef]);
126
+
127
+ const waitThenRetry = useCallback(
128
+ (gen: number, why: string, wait: (retry: () => void) => () => void) => {
129
+ clearTimeout(readyTimerRef.current);
130
+ budgetGenRef.current = null;
131
+ stopHoldRef.current();
132
+ logReload("shadow-held", { gen, why });
133
+ stopHoldRef.current = wait(() => {
134
+ logReload("shadow-hold-ended", { gen, why });
135
+ promoteWhenReadyRef.current(gen);
136
+ });
137
+ return true;
138
+ },
139
+ [],
140
+ );
141
+
142
+ // Nothing replaces the preview under a gesture, and a shadow requested before a gesture's save
143
+ // shows the file before it: once that save has landed, a fresh one loads instead.
144
+ const heldOrStale = useCallback(
145
+ (gen: number) => {
146
+ const liveDoc = iframeRef.current?.contentDocument;
147
+ if (liveDoc && isStudioManualEditGestureLiveIn(liveDoc))
148
+ return waitThenRetry(gen, "gesture", (retry) =>
149
+ afterStudioManualEditGestures(liveDoc, retry),
150
+ );
151
+ if (gestureSavesOnScreen() === savesAtRequestRef.current) return false;
152
+ if (isStudioEditSaving()) return waitThenRetry(gen, "save", afterStudioPendingEdits);
153
+ logReload("shadow-reloaded-after-gesture-save", { gen });
154
+ const url = new URL(shadowUrlRef.current, window.location.origin);
155
+ url.searchParams.set("_t", String(Date.now()));
156
+ beginShadowReloadRef.current(url.toString());
157
+ return true;
158
+ },
159
+ [iframeRef, gestureSavesOnScreen, waitThenRetry],
160
+ );
161
+
162
+ const shadowReadyToPromote = useCallback((gen: number) => {
163
+ const shadow = shadowIframeRef.current;
164
+ const pending = pendingCommitRef.current;
165
+ const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
166
+ return shadow && pending && ready && gen === shadowGenRef.current ? { shadow, pending } : null;
167
+ }, []);
168
+
109
169
  const promoteWhenReady = useCallback(
110
170
  (gen: number) => {
111
- const shadow = shadowIframeRef.current;
112
- const pending = pendingCommitRef.current;
113
- const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
114
- if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
171
+ const promotable = shadowReadyToPromote(gen);
172
+ if (!promotable || heldOrStale(gen)) return;
173
+ const { shadow, pending } = promotable;
115
174
  stopPendingShadow();
116
175
  // The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
117
176
  const live = getAdapter();
@@ -119,6 +178,7 @@ export function useShadowPreviewReload({
119
178
  const playing = usePlayerStore.getState().isPlaying;
120
179
  live?.pause();
121
180
  // The store takes the new document's timeline only now that it is the one on screen, and reads it there.
181
+ const retired = iframeRef.current;
122
182
  iframeRef.current = shadow;
123
183
  pending.commit();
124
184
  shadowIframeRef.current = null;
@@ -126,17 +186,21 @@ export function useShadowPreviewReload({
126
186
  applyPreviewAudioState();
127
187
  setPreviewSlots((prev) => planShadowPromotion(prev, gen));
128
188
  if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
189
+ announcePreviewPromoted({ retired, live: shadow });
129
190
  onPromotedRef.current?.();
130
191
  thumbnailScheduler.setPreviewReloading(false);
131
192
  },
132
193
  [
133
194
  stopPendingShadow,
195
+ shadowReadyToPromote,
196
+ heldOrStale,
134
197
  getAdapter,
135
198
  iframeRef,
136
199
  attachIframeShortcutListeners,
137
200
  applyPreviewAudioState,
138
201
  ],
139
202
  );
203
+ promoteWhenReadyRef.current = promoteWhenReady;
140
204
 
141
205
  const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
142
206
  const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
@@ -216,13 +280,16 @@ export function useShadowPreviewReload({
216
280
  stopPendingShadow();
217
281
  budgetGenRef.current = gen;
218
282
  budgetsSpentRef.current = 0;
283
+ shadowUrlRef.current = url;
284
+ savesAtRequestRef.current = gestureSavesOnScreen();
219
285
  armReadyTimer(gen);
220
286
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
221
287
  // Thumbnails of the edit wait for the new preview instead of competing with it.
222
288
  thumbnailScheduler.setPreviewReloading(true);
223
289
  },
224
- [stopPendingShadow, armReadyTimer],
290
+ [stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
225
291
  );
292
+ beginShadowReloadRef.current = beginShadowReload;
226
293
 
227
294
  // Composition switch (not an edit reload): drop any in-flight shadow.
228
295
  const resetPreviewSlots = useCallback(() => {
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
- import { syncStoredAutomationFromPreview } from "./automationStoreSync";
3
+ import { syncStoredAutomationFromPreview, syncStoredElementAttribute } from "./automationStoreSync";
4
4
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
5
 
6
6
  const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
@@ -32,7 +32,24 @@ beforeEach(() => {
32
32
  usePlayerStore.getState().reset();
33
33
  });
34
34
 
35
+ describe("syncStoredElementAttribute", () => {
36
+ it("records a saved fade on the stored clip, so the timeline draws what the file holds", () => {
37
+ usePlayerStore.setState({ elements: [el({ fadeIn: 1 })] });
38
+ syncStoredElementAttribute(el(), "data-fade-in", "2.5");
39
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBe(2.5);
40
+ syncStoredElementAttribute(el(), "data-fade-in", null);
41
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBeUndefined();
42
+ });
43
+ });
44
+
35
45
  describe("syncStoredAutomationFromPreview", () => {
46
+ it("reads back a fade an undo restored on the preview", () => {
47
+ usePlayerStore.setState({ elements: [el({ fadeOut: 2 })] });
48
+ syncStoredAutomationFromPreview(previewWith({ "data-fade-in": "0.6" }));
49
+ const stored = usePlayerStore.getState().elements[0];
50
+ expect([stored?.fadeIn, stored?.fadeOut]).toEqual([0.6, undefined]);
51
+ });
52
+
36
53
  it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
37
54
  usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
38
55
  const doc = document.implementation.createHTMLDocument("preview");
@@ -16,12 +16,18 @@
16
16
 
17
17
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
18
18
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
19
+ import {
20
+ HF_AUDIO_FADE_IN_ATTR,
21
+ HF_AUDIO_FADE_OUT_ATTR,
22
+ readElementFades,
23
+ readFadeSeconds,
24
+ } from "@hyperframes/core/audio-fade";
19
25
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
20
26
  import { groupInfoFor } from "./timelineGroupInfo";
21
27
  import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
22
28
 
23
29
  /**
24
- * Re-read every element's automation and FX-chain attributes from the preview
30
+ * Re-read every element's automation, FX-chain and fade attributes from the preview
25
31
  * document, for a change that reached the DOM without going through this store.
26
32
  *
27
33
  * That is undo and redo. A soft restore patches the reverted attributes onto the
@@ -34,21 +40,21 @@ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElem
34
40
  * reverted is only known by looking.
35
41
  */
36
42
  /**
37
- * What an element's four synced fields SHOULD read, given the preview.
43
+ * What an element's six synced fields SHOULD read, given the preview.
38
44
  *
39
- * Its own two come off its node; the other two are its copy of what its group
45
+ * Its own four come off its node; the other two are its copy of what its group
40
46
  * carries. The timeline derives a group's lanes and chain from these mirrors,
41
- * never from the group element — and the FX rack is not group-aware: selecting
42
- * a group and automating one of its parameters writes `data-automation` on the
43
- * group node through the ordinary element path, which used to refresh an
44
- * element's own two fields and nothing else. So the group's row went on reading
45
- * the value it was born with, and its `∿` never appeared.
47
+ * never from the group element, and automating a group writes `data-automation`
48
+ * on the group node through the ordinary element path, so both halves are re-read.
46
49
  */
47
50
  function syncedFields(doc: Document, element: TimelineElement, node: Element) {
48
51
  const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
52
+ const fades = readElementFades(node);
49
53
  return {
50
54
  automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
51
55
  fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
56
+ fadeIn: storedFade(fades.fadeIn),
57
+ fadeOut: storedFade(fades.fadeOut),
52
58
  audioGroupAutomation: group?.automation,
53
59
  audioGroupFxChain: group?.fxChain,
54
60
  };
@@ -74,27 +80,33 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
74
80
  });
75
81
  }
76
82
 
77
- const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
78
- [HF_AUDIO_AUTOMATION_ATTR]: "automation",
79
- [HF_AUDIO_FX_ATTR]: "fxChain",
83
+ /** Discovery's rule (applyFadeMetadataFromElement): a fade of 0 is no fade. */
84
+ const storedFade = (seconds: number) => (seconds > 0 ? seconds : undefined);
85
+
86
+ const STORED_FIELD: Record<string, (value: string | null) => Partial<TimelineElement>> = {
87
+ [HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ automation: value ?? undefined }),
88
+ [HF_AUDIO_FX_ATTR]: (value) => ({ fxChain: value ?? undefined }),
89
+ [HF_AUDIO_FADE_IN_ATTR]: (value) => ({ fadeIn: storedFade(readFadeSeconds(value)) }),
90
+ [HF_AUDIO_FADE_OUT_ATTR]: (value) => ({ fadeOut: storedFade(readFadeSeconds(value)) }),
80
91
  };
81
92
 
82
- /** Record a saved automation or FX-chain value on one element's stored copy. */
93
+ /** Record a saved automation, FX-chain or fade value on one element's stored copy. */
83
94
  export function syncStoredElementAttribute(
84
95
  target: TimelineElement,
85
96
  attr: string,
86
97
  value: string | null,
87
98
  ): void {
88
- const field = STORED_FIELD[attr];
89
- if (!field) return;
99
+ const fields = STORED_FIELD[attr]?.(value);
100
+ if (!fields) return;
90
101
  const key = getTimelineElementIdentity(target);
91
- const next = value ?? undefined;
102
+ const same = (element: TimelineElement) =>
103
+ (Object.keys(fields) as (keyof TimelineElement)[]).every((k) => element[k] === fields[k]);
92
104
  usePlayerStore.setState((state) => {
93
105
  let changed = false;
94
106
  const elements = state.elements.map((element) => {
95
- if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
107
+ if (getTimelineElementIdentity(element) !== key || same(element)) return element;
96
108
  changed = true;
97
- return { ...element, [field]: next };
109
+ return { ...element, ...fields };
98
110
  });
99
111
  return changed ? { elements } : {};
100
112
  });
@@ -23,6 +23,8 @@ export interface ThumbnailRequest {
23
23
  priority: ThumbnailPriority;
24
24
  /** Rich work is paused while the timeline is fast-scrolling. */
25
25
  rich?: boolean;
26
+ /** For work whose result nobody reads: it is dropped, not cached, once its last lease ends. */
27
+ discardWhenReleased?: boolean;
26
28
  load: (signal: AbortSignal) => Promise<ThumbnailLoadedResult>;
27
29
  }
28
30
 
@@ -214,10 +216,7 @@ export class ThumbnailScheduler {
214
216
  if (!scrolling) this.pump();
215
217
  }
216
218
 
217
- /**
218
- * Hold server-rendered composition thumbnails while the preview loads a new document: both
219
- * are served by the same Studio server, and the preview is what the person is waiting for.
220
- */
219
+ /** Holds composition renders while the preview loads: same server, and the preview is what the person awaits. */
221
220
  setPreviewReloading(reloading: boolean): void {
222
221
  if (this.previewReloading === reloading) return;
223
222
  this.previewReloading = reloading;
@@ -362,7 +361,7 @@ export class ThumbnailScheduler {
362
361
  const byteBudget = isWaveform
363
362
  ? this.budgets.waveformCacheBytes
364
363
  : this.budgets.thumbnailCacheBytes;
365
- entry.cached = entry.weight <= byteBudget;
364
+ entry.cached = !entry.request.discardWhenReleased && entry.weight <= byteBudget;
366
365
  if (!entry.cached) return;
367
366
  if (isWaveform) this.waveformCacheBytes += entry.weight;
368
367
  else this.cacheBytes += entry.weight;
@@ -23,7 +23,7 @@ describe("timeline viewport budgets", () => {
23
23
  constrainedLongTaskLimitMs: 300,
24
24
  posterCoverageRatio: 0.9,
25
25
  supportedFixtureFallbackRatio: 0.02,
26
- scrollSamplesPerRun: 21,
26
+ scrollSamplesPerRun: 63,
27
27
  warmupRuns: 3,
28
28
  measuredRuns: 5,
29
29
  requiredPassingRuns: 4,
@@ -99,7 +99,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
99
99
  richPreviewP95Ms: 750,
100
100
  constrainedRichPreviewP95Ms: 1_200,
101
101
  supportedFixtureFallbackRatio: 0.02,
102
- scrollSamplesPerRun: 21,
102
+ scrollSamplesPerRun: 63,
103
103
  warmupRuns: 3,
104
104
  measuredRuns: 5,
105
105
  requiredPassingRuns: 4,
@@ -17,6 +17,24 @@ export function onPreviewContentReplaced(
17
17
  };
18
18
  }
19
19
 
20
+ const PREVIEW_PROMOTED = "hf-preview-promoted";
21
+ export type PreviewPromotion = { retired: HTMLIFrameElement | null; live: HTMLIFrameElement };
22
+
23
+ export function announcePreviewPromoted(promotion: PreviewPromotion): void {
24
+ promotion.live.ownerDocument.dispatchEvent(
25
+ new CustomEvent(PREVIEW_PROMOTED, { detail: promotion }),
26
+ );
27
+ }
28
+
29
+ export function onPreviewPromoted(
30
+ doc: Document,
31
+ listener: (promotion: PreviewPromotion) => void,
32
+ ): () => void {
33
+ const handle = (event: Event) => listener((event as CustomEvent<PreviewPromotion>).detail);
34
+ doc.addEventListener(PREVIEW_PROMOTED, handle);
35
+ return () => doc.removeEventListener(PREVIEW_PROMOTED, handle);
36
+ }
37
+
20
38
  export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
21
39
  const doc = iframe?.contentDocument;
22
40
  const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');