@hyperframes/studio 0.8.79 → 0.8.81

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 (86) hide show
  1. package/dist/assets/hyperframes-player-C68UawR3.js +464 -0
  2. package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
  3. package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
  4. package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1673 -1348
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +13 -10
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  13. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  14. package/src/components/editor/manualEdits.ts +1 -0
  15. package/src/components/editor/manualEditsParsing.ts +4 -0
  16. package/src/components/editor/useFxAudition.ts +3 -3
  17. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  18. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  19. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  20. package/src/components/panels/VariablesPanel.tsx +3 -3
  21. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  22. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  23. package/src/components/studioColorGradingScope.ts +5 -7
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/contexts/TimelineEditContext.tsx +2 -0
  26. package/src/hooks/liveLanes.ts +147 -0
  27. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  28. package/src/hooks/timelineEditPermission.ts +9 -1
  29. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  30. package/src/hooks/timelineEditingHelpers.ts +69 -43
  31. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  32. package/src/hooks/useAppHotkeys.ts +1 -1
  33. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  34. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  35. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  36. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  37. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -4
  39. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  40. package/src/hooks/useDomEditSession.ts +1 -0
  41. package/src/hooks/useEditHistoryActions.ts +4 -3
  42. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  43. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  44. package/src/hooks/useLintModal.test.tsx +15 -0
  45. package/src/hooks/useLintModal.ts +5 -2
  46. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  47. package/src/hooks/useTimelineEditing.ts +19 -16
  48. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  49. package/src/hooks/useToast.ts +2 -1
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +23 -0
  52. package/src/player/components/Player.tsx +4 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/groupAutomationElement.ts +9 -1
  64. package/src/player/components/timelineCallbacks.ts +2 -0
  65. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  66. package/src/player/components/timelineClipDragPreview.ts +15 -24
  67. package/src/player/components/timelineEditing.test.ts +99 -1
  68. package/src/player/components/timelineEditing.ts +5 -8
  69. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  70. package/src/player/components/timelineGroupEditing.ts +18 -15
  71. package/src/player/components/timelineStackingSync.ts +5 -5
  72. package/src/player/components/timelineZones.ts +2 -3
  73. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  74. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  75. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  76. package/src/player/components/useAutomationLanes.ts +58 -74
  77. package/src/player/lib/automationStoreSync.ts +30 -3
  78. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  79. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -8
  81. package/src/player/lib/timelineElementHelpers.ts +3 -4
  82. package/src/player/lib/timelineGroupInfo.ts +23 -23
  83. package/src/utils/studioFileHistory.ts +36 -30
  84. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  85. package/src/player/components/automationGestureKeys.test.ts +0 -49
  86. package/src/player/components/automationGestureKeys.ts +0 -49
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { mountReactHarness } from "./domSelectionTestHarness";
6
+ import { useBlockHandlers, type UseBlockHandlersResult } from "./useBlockHandlers";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ vi.mock("../utils/blockInstaller", () => ({
11
+ addBlockToProject: async (opts: { showToast: (message: string) => void }) => {
12
+ opts.showToast("Installing catalog items needs hyperframes preview");
13
+ return null;
14
+ },
15
+ }));
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ describe("useBlockHandlers", () => {
22
+ it("leaves only the error toast when an install fails", async () => {
23
+ const events: string[] = [];
24
+ let nextId = 0;
25
+ const showToast = (message: string) => {
26
+ events.push(`show ${message}`);
27
+ return ++nextId;
28
+ };
29
+ const dismissToast = (id: number) => events.push(`dismiss ${id}`);
30
+ let handlers: UseBlockHandlersResult | undefined;
31
+ function Harness() {
32
+ handlers = useBlockHandlers({
33
+ projectId: "demo",
34
+ blockCtxDeps: {
35
+ activeCompPath: null,
36
+ timelineElements: [],
37
+ readProjectFile: async () => "",
38
+ writeProjectFile: async () => {},
39
+ recordEdit: async () => {},
40
+ refreshFileTree: async () => {},
41
+ reloadPreview: () => {},
42
+ showToast,
43
+ dismissToast,
44
+ },
45
+ previewIframeRef: { current: null },
46
+ setRightCollapsed: () => {},
47
+ setRightPanelTab: () => {},
48
+ });
49
+ return null;
50
+ }
51
+ const root = mountReactHarness(<Harness />);
52
+
53
+ await act(async () => {
54
+ handlers!.handleAddBlock("x-post");
55
+ await new Promise((resolve) => setTimeout(resolve, 0));
56
+ });
57
+
58
+ expect(events).toEqual([
59
+ "show Adding x-post…",
60
+ "show Installing catalog items needs hyperframes preview",
61
+ "dismiss 1",
62
+ ]);
63
+ act(() => root.unmount());
64
+ });
65
+ });
@@ -23,7 +23,8 @@ interface BlockCtxDeps {
23
23
  }) => Promise<void>;
24
24
  refreshFileTree: () => Promise<void>;
25
25
  reloadPreview: () => void;
26
- showToast: (message: string, tone?: "error" | "info") => void;
26
+ showToast: (message: string, tone?: "error" | "info") => number;
27
+ dismissToast: (id: number) => void;
27
28
  }
28
29
 
29
30
  interface UseBlockHandlersParams {
@@ -87,21 +88,23 @@ export function useBlockHandlers({
87
88
  // Block installs hit the server and end in a full preview reload; without a
88
89
  // guard, repeat drops while one is in flight stack duplicate installs.
89
90
  const installingBlockRef = useRef(false);
91
+ const { showToast, dismissToast } = blockCtxDeps;
90
92
  const runBlockInstall = useCallback(
91
93
  async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
92
94
  if (installingBlockRef.current) {
93
- blockCtx.showToast("A block is already installing — one moment…", "info");
95
+ showToast("A block is already installing — one moment…", "info");
94
96
  return null;
95
97
  }
96
98
  installingBlockRef.current = true;
97
- blockCtx.showToast(`Adding ${blockName}…`, "info");
99
+ const progress = showToast(`Adding ${blockName}…`, "info");
98
100
  try {
99
101
  return await install();
100
102
  } finally {
101
103
  installingBlockRef.current = false;
104
+ dismissToast(progress);
102
105
  }
103
106
  },
104
- [blockCtx],
107
+ [showToast, dismissToast],
105
108
  );
106
109
 
107
110
  const handleAddBlock = useCallback(
@@ -29,15 +29,6 @@ interface DataAttributeCommitOptions {
29
29
  skipRefresh: boolean;
30
30
  refreshAfter?: boolean;
31
31
  onSettled?: (ok: boolean) => void;
32
- /**
33
- * Undo grouping for a gesture that spans several commits.
34
- *
35
- * Without it the key is derived from the prefix, attribute and element, and the
36
- * window is history's own 300ms — so a drag's moves and the release that ends it
37
- * landed in different entries, and a drag slower than the window split further.
38
- * A caller that knows a gesture is in progress passes one key for all of it.
39
- */
40
- coalesce?: { key: string; ms: number };
41
32
  /**
42
33
  * Apply to the preview and stop there — no file write, no history entry.
43
34
  *
@@ -150,9 +141,7 @@ export function useDomEditAttributeCommits({
150
141
  if (!domEditSelection) return;
151
142
  const iframe = previewIframeRef.current;
152
143
  const fullAttr = resolveFullAttrName(attr, true);
153
- const commitKey =
154
- options.coalesce?.key ??
155
- `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
144
+ const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
156
145
  const isLatestCommit = bumpDomEditCommitMapVersion(
157
146
  domAttributeCommitVersionRef.current,
158
147
  commitKey,
@@ -184,7 +173,6 @@ export function useDomEditAttributeCommits({
184
173
  persistDomEditOperations(domEditSelection, [op], {
185
174
  label: options.label,
186
175
  coalesceKey: commitKey,
187
- ...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
188
176
  skipRefresh: options.skipRefresh,
189
177
  }),
190
178
  shouldRevert: () => isLatestCommit(),
@@ -338,14 +326,13 @@ export function useDomEditAttributeCommits({
338
326
  attr: string,
339
327
  value: string | null,
340
328
  onSettled?: (ok: boolean) => void,
341
- live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean },
329
+ live?: { previewOnly?: boolean },
342
330
  ) => {
343
331
  await commitDataAttribute(attr, value, {
344
332
  label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
345
333
  coalescePrefix: "attr-live",
346
334
  skipRefresh: true,
347
335
  onSettled,
348
- ...(live?.coalesce ? { coalesce: live.coalesce } : {}),
349
336
  ...(live?.previewOnly ? { previewOnly: true } : {}),
350
337
  });
351
338
  },
@@ -362,13 +349,12 @@ export function useDomEditAttributeCommits({
362
349
  * edit computes from a pre-edit value and appears to do nothing.
363
350
  */
364
351
  const handleDomAttributeQuietCommit = useCallback(
365
- async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => {
352
+ async (attr: string, value: string | null) => {
366
353
  await commitDataAttribute(attr, value, {
367
354
  label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
368
355
  coalescePrefix: "attr-quiet",
369
356
  skipRefresh: true,
370
357
  refreshAfter: true,
371
- ...(coalesce ? { coalesce } : {}),
372
358
  });
373
359
  },
374
360
  [commitDataAttribute],
@@ -511,6 +511,7 @@ export function useDomEditSession({
511
511
  handlePreviewCanvasPointerLeave,
512
512
  applyDomSelection,
513
513
  clearDomSelection,
514
+ refreshDomEditSelectionFromPreview,
514
515
  handleDomStyleCommit,
515
516
  handleDomStyleCommitForSelection,
516
517
  handleDomAttributeCommit,
@@ -33,7 +33,7 @@ export interface UseEditHistoryActionsOptions {
33
33
  showToast: (message: string, tone?: "error" | "info") => void;
34
34
  syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
35
35
  waitForPendingDomEditSaves: () => Promise<void>;
36
- onAfterUndoRedo?: () => void;
36
+ onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
37
37
  /** Active composition path — decides whether undo/redo must resync the SDK session. */
38
38
  activeCompPath?: string | null;
39
39
  /** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
@@ -84,11 +84,12 @@ export function useEditHistoryActions({
84
84
  return;
85
85
  }
86
86
  if (result.ok && result.label) {
87
- onAfterUndoRedo?.();
87
+ const restore = { paths: result.paths, files: result.files };
88
+ onAfterUndoRedo?.(restore);
88
89
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
89
90
  forceReloadSdkSession?.();
90
91
  }
91
- await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
92
+ await syncHistoryPreviewAfterApply(restore);
92
93
  showToast(result.label, "info");
93
94
  }
94
95
  },
@@ -94,6 +94,55 @@ describe("external file change coordinator", () => {
94
94
  expect(refreshFileTree).toHaveBeenCalledOnce();
95
95
  });
96
96
 
97
+ it("refreshes the file tree but not Preview for a file the preview never loaded", async () => {
98
+ const order: string[] = [];
99
+ await mountCoordinator({
100
+ reloadPreview: () => order.push("preview"),
101
+ reloadSdkSession: () => order.push("sdk"),
102
+ refreshFileTree: () => {
103
+ order.push("tree");
104
+ },
105
+ });
106
+ await act(async () =>
107
+ handler?.({ path: "notes.md", content: "notes", version: "v1", affectsPreview: false }),
108
+ );
109
+ expect(order).toEqual(["sdk", "tree"]);
110
+ });
111
+
112
+ it("still reloads Preview when a notes change replaces a waiting film change", async () => {
113
+ let release = () => {};
114
+ const inFlight = new Promise<void>((resolve) => (release = resolve));
115
+ const reloadPreview = vi.fn();
116
+ const reloadSdkSession = vi.fn();
117
+ const onAcceptedPersistedFileChange = vi.fn();
118
+ let drains = 0;
119
+ await mountCoordinator({
120
+ reloadPreview,
121
+ reloadSdkSession,
122
+ onAcceptedPersistedFileChange,
123
+ drainPendingChanges: async () => {
124
+ if (drains++ === 0) await inFlight;
125
+ return { status: "clean" as const };
126
+ },
127
+ });
128
+ await act(async () => handler?.({ path: "index.html", content: "a", version: "v1" }));
129
+ await act(async () =>
130
+ handler?.({
131
+ path: "scene.html",
132
+ content: "b",
133
+ version: "v2",
134
+ affectedCompositions: ["scene.html"],
135
+ }),
136
+ );
137
+ await act(async () =>
138
+ handler?.({ path: "notes.md", content: "c", version: "v3", affectsPreview: false }),
139
+ );
140
+ await act(async () => release());
141
+ expect(reloadPreview).toHaveBeenCalledTimes(2);
142
+ expect(reloadSdkSession).toHaveBeenLastCalledWith("scene.html");
143
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
144
+ });
145
+
97
146
  it("does not refresh the tree for a suppressed self-write echo", async () => {
98
147
  const refreshFileTree = vi.fn();
99
148
  await mountCoordinator({ refreshFileTree });
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
2
  import {
3
3
  readFileChangeAffectedCompositions,
4
+ readFileChangeAffectsPreview,
4
5
  readFileChangeField,
5
6
  readStudioFileChangePath,
6
7
  } from "../components/editor/manualEdits";
@@ -231,9 +232,12 @@ export function useExternalFileChangeCoordinator({
231
232
  }, [loadConflictSnapshot, projectId, recoveryFilePath]);
232
233
 
233
234
  const reloadAcceptedGeneration = useCallback(
234
- (path: string) => {
235
- logReload("reload", { path, by: "external-change coordinator" });
236
- reloadPreview();
235
+ (path: string, affectsPreview = true) => {
236
+ logReload(affectsPreview ? "reload" : "file-tree only", {
237
+ path,
238
+ by: "external-change coordinator",
239
+ });
240
+ if (affectsPreview) reloadPreview();
237
241
  reloadSdkSession(path);
238
242
  // Fire-and-forget: a failed refresh leaves the tree as stale as it was,
239
243
  // which is the status quo this exists to improve on, not a new failure
@@ -279,7 +283,7 @@ export function useExternalFileChangeCoordinator({
279
283
  if (!mountedRef.current || generation !== generationRef.current) return;
280
284
  setBlocked(null);
281
285
  onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
282
- reloadAcceptedGeneration(path);
286
+ reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
283
287
  return;
284
288
  }
285
289
  const content = readFileChangeContent(payload);
@@ -410,7 +414,12 @@ export function useExternalFileChangeCoordinator({
410
414
  return;
411
415
  }
412
416
 
413
- pendingPayloadRef.current = { payload };
417
+ const waiting = pendingPayloadRef.current?.payload;
418
+ const waitingChangeOutranksThis =
419
+ waiting != null &&
420
+ readFileChangeAffectsPreview(waiting) &&
421
+ !readFileChangeAffectsPreview(payload);
422
+ if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
414
423
  void startDrainLoop();
415
424
  },
416
425
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
@@ -456,7 +465,7 @@ export function useExternalFileChangeCoordinator({
456
465
  await deleteConflictSnapshot?.(projectId, path);
457
466
  setBlocked(null);
458
467
  onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
459
- reloadAcceptedGeneration(path);
468
+ reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload));
460
469
  },
461
470
  [
462
471
  deleteConflictSnapshot,
@@ -69,6 +69,21 @@ describe("useLintModal hasLintError", () => {
69
69
  });
70
70
  });
71
71
 
72
+ describe("useLintModal lintFindingCount", () => {
73
+ it("counts findings, not files, with the result open and after it closes", async () => {
74
+ const findings = ["a", "b", "c"].map((message) => ({
75
+ severity: "warning",
76
+ message,
77
+ file: "index.html",
78
+ }));
79
+ await mountWithLint(async () => ({ json: async () => ({ findings }) }));
80
+ expect(latest?.lintFindingCount).toBe(3);
81
+ act(() => latest?.closeLintModal());
82
+ expect(latest?.findingsByFile.size).toBe(1);
83
+ expect(latest?.lintFindingCount).toBe(3);
84
+ });
85
+ });
86
+
72
87
  describe("useLintModal background lint", () => {
73
88
  it("waits for the live preview to boot before asking the server to lint", async () => {
74
89
  usePlayerStore.setState({ previewBooted: false });
@@ -98,8 +98,10 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
98
98
 
99
99
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
100
100
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
101
- // Reads the list the badge count reads: the manual result when present, else the background one.
102
- const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
101
+ // The badge counts findings, not files: the manual result when present, else the background one.
102
+ const badgeFindings = lintModal ?? backgroundFindings;
103
+ const lintFindingCount = badgeFindings.length;
104
+ const hasLintError = badgeFindings.some((f) => f.severity === "error");
103
105
 
104
106
  // Sync lint findings directly to the player store — eliminates the
105
107
  // mirroring useEffect that was previously in App.tsx.
@@ -115,6 +117,7 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
115
117
  backgroundFindings,
116
118
  findingsByElement,
117
119
  findingsByFile,
120
+ lintFindingCount,
118
121
  hasLintError,
119
122
  };
120
123
  }
@@ -51,7 +51,7 @@ async function readGroup(
51
51
  * full parse, so large projects don't pay N openComposition calls.
52
52
  */
53
53
  export function useProjectCompositionVariables(
54
- fileTree: string[],
54
+ compositionPaths: string[],
55
55
  excludePath: string | null,
56
56
  readProjectFile: (path: string) => Promise<string>,
57
57
  refreshKey: unknown,
@@ -60,7 +60,7 @@ export function useProjectCompositionVariables(
60
60
 
61
61
  useEffect(() => {
62
62
  let cancelled = false;
63
- const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
63
+ const htmlFiles = compositionPaths.filter((p) => p !== excludePath);
64
64
 
65
65
  void (async () => {
66
66
  const out: CompositionVariableGroup[] = [];
@@ -75,7 +75,7 @@ export function useProjectCompositionVariables(
75
75
  cancelled = true;
76
76
  };
77
77
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
- }, [fileTree, excludePath, readProjectFile, refreshKey]);
78
+ }, [compositionPaths, excludePath, readProjectFile, refreshKey]);
79
79
 
80
80
  return groups;
81
81
  }
@@ -11,6 +11,7 @@ import {
11
11
  playbackStartAttributeForElement,
12
12
  persistTimelineEdit,
13
13
  formatTimelineAttributeNumber,
14
+ formatTimelineMediaOffset,
14
15
  extendRootDurationIfNeeded,
15
16
  buildTimelineMoveTimingPatch,
16
17
  buildTimelineResizeTimingPatch,
@@ -313,7 +314,7 @@ export function useTimelineEditing({
313
314
  ];
314
315
  if (updates.playbackStart != null) {
315
316
  const liveAttr = playbackStartAttributeForElement(element);
316
- liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
317
+ liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
317
318
  }
318
319
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
319
320
  // Snapshot the duration BEFORE the optimistic updates below so a failed
@@ -436,7 +437,7 @@ export function useTimelineEditing({
436
437
  checkEditable,
437
438
  });
438
439
 
439
- const { revertLive: revertElementFxLive, ...setElementFxAttribute } = useSetElementAttribute({
440
+ const setElementFxAttribute = useSetElementAttribute({
440
441
  projectIdRef,
441
442
  activeCompPath,
442
443
  showToast,
@@ -447,18 +448,16 @@ export function useTimelineEditing({
447
448
  isRecordingRef,
448
449
  });
449
450
 
450
- const { revertLive: revertAudioGroupLive, ...setAudioGroupAttribute } = useSetAudioGroupAttribute(
451
- {
452
- projectIdRef,
453
- activeCompPath,
454
- showToast,
455
- writeProjectFile,
456
- recordEdit,
457
- previewIframeRef,
458
- pendingTimelineEditPathRef,
459
- isRecordingRef,
460
- },
461
- );
451
+ const setAudioGroupAttribute = useSetAudioGroupAttribute({
452
+ projectIdRef,
453
+ activeCompPath,
454
+ showToast,
455
+ writeProjectFile,
456
+ recordEdit,
457
+ previewIframeRef,
458
+ pendingTimelineEditPathRef,
459
+ isRecordingRef,
460
+ });
462
461
 
463
462
  const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
464
463
  projectIdRef,
@@ -550,7 +549,7 @@ export function useTimelineEditing({
550
549
  // no flat twin, only a domClipChildren entry, so both are checked.
551
550
  setQuiet: track(
552
551
  guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
553
- revertAudioGroupLive(groupId, attr);
552
+ setAudioGroupAttribute.revertLive(groupId, attr);
554
553
  return refused(reason);
555
554
  }),
556
555
  ),
@@ -562,7 +561,7 @@ export function useTimelineEditing({
562
561
  (element) => [element],
563
562
  setElementFxAttribute.setQuiet,
564
563
  (reason, element, attr) => {
565
- revertElementFxLive(element, attr);
564
+ setElementFxAttribute.revertLive(element, attr);
566
565
  return refused(reason);
567
566
  },
568
567
  ),
@@ -591,5 +590,9 @@ export function useTimelineEditing({
591
590
  handleTimelineGroupResize: track(
592
591
  guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
593
592
  ),
593
+ restoreLiveLanes: (restore: Parameters<typeof setElementFxAttribute.restoreLive>[0]) => {
594
+ setElementFxAttribute.restoreLive(restore);
595
+ setAudioGroupAttribute.restoreLive(restore);
596
+ },
594
597
  };
595
598
  }
@@ -13,6 +13,7 @@ import {
13
13
  buildTimelineResizeTimingPatch,
14
14
  extendRootDurationIfNeeded,
15
15
  formatTimelineAttributeNumber,
16
+ formatTimelineMediaOffset,
16
17
  patchIframeDomTiming,
17
18
  playbackStartAttributeForElement,
18
19
  persistTimelineBatchEdit,
@@ -377,7 +378,7 @@ export function useTimelineGroupEditing({
377
378
  ];
378
379
  if (change.playbackStart != null) {
379
380
  const liveAttr = playbackStartAttributeForElement(change.element);
380
- liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
381
+ liveAttrs.push([liveAttr, formatTimelineMediaOffset(change.playbackStart)]);
381
382
  }
382
383
  patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
383
384
  }
@@ -50,7 +50,7 @@ export function useToast() {
50
50
  );
51
51
 
52
52
  const showToast = useCallback(
53
- (message: string, tone: AppToast["tone"] = "error") => {
53
+ (message: string, tone: AppToast["tone"] = "error"): number => {
54
54
  const id = nextToastId++;
55
55
  setToasts((prev) => {
56
56
  const next = [...prev, { id, message, tone }];
@@ -65,6 +65,7 @@ export function useToast() {
65
65
  const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
66
66
  timersRef.current.set(id, timer);
67
67
  }
68
+ return id;
68
69
  },
69
70
  [clearTimer, dismissToast],
70
71
  );
@@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useRef, useState } 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";
5
- import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
6
- import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
5
+ import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
6
+ import { computeThumbnailStrip } from "./thumbnailUtils";
7
7
 
8
8
  export interface ImageThumbnailProps {
9
9
  imageSrc: string;
@@ -35,16 +35,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
35
35
  kind: "image" as const,
36
36
  priority,
37
37
  rich,
38
- load: async (signal: AbortSignal) => {
39
- const aspect = await probeImageAspect(imageSrc, signal, true);
40
- return {
41
- value: { kind: "image" as const, url: imageSrc, aspect },
42
- weight:
43
- TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
44
- TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
45
- 4,
46
- };
47
- },
38
+ load: (signal: AbortSignal) => decodeImageThumbnail(imageSrc, signal),
48
39
  }),
49
40
  [imageSrc, priority, projectId, rich, sessionEpoch],
50
41
  );
@@ -277,6 +277,29 @@ describe("ready to show", () => {
277
277
  expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
278
278
  });
279
279
 
280
+ it("can show a document whose player was ready and painted before its load event", async () => {
281
+ const onReadyToShowChange = vi.fn();
282
+ const { player } = await mountPlayer({ onReadyToShowChange });
283
+ const el = Object.assign(player as TestHyperframesPlayer, { ready: true, painted: true });
284
+
285
+ act(() => {
286
+ el.dispatchEvent(new Event("ready"));
287
+ el.dispatchEvent(new Event("painted"));
288
+ el.iframeElement.dispatchEvent(new Event("load"));
289
+ });
290
+ await twoFrames();
291
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
292
+
293
+ for (const state of [
294
+ { ready: false, painted: true },
295
+ { ready: true, painted: false },
296
+ ]) {
297
+ Object.assign(el, state);
298
+ act(() => void el.iframeElement.dispatchEvent(new Event("load")));
299
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
300
+ }
301
+ });
302
+
280
303
  it("marks the preview booted when it can show and play, not at ready", async () => {
281
304
  usePlayerStore.setState({ previewBooted: false });
282
305
  const { player } = await mountPlayer({});
@@ -39,6 +39,8 @@ interface PlayerProps {
39
39
 
40
40
  interface HyperframesPlayerElement extends HTMLElement {
41
41
  iframeElement: HTMLIFrameElement;
42
+ ready?: boolean;
43
+ painted?: boolean;
42
44
  }
43
45
 
44
46
  const MEDIA_HAVE_FUTURE_DATA = 3;
@@ -245,8 +247,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
245
247
  setLoaded(true);
246
248
  setPreviewError(null);
247
249
  setShaderTransitionLoading(false);
248
- setCompositionLoading(true);
249
- setPainted(false);
250
+ setCompositionLoading(!player.ready);
251
+ setPainted(Boolean(player.painted));
250
252
  // Reveal animation on reload (hot-reload, composition switch)
251
253
  if (loadCountRef.current > 1) {
252
254
  container.classList.remove("preview-revealing");
@@ -338,6 +338,24 @@ describe("TimelineAutomationLane", () => {
338
338
  );
339
339
  });
340
340
 
341
+ it("drops the dragged position when the save does not land", async () => {
342
+ const onCommit = vi.fn(async () => ({ status: "refused" as const, reason: "Locked" }));
343
+ const { container } = render(
344
+ <TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
345
+ );
346
+ const svg = container.querySelector("svg")!;
347
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
348
+ const before = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
349
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
350
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
351
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
352
+ await act(async () => {});
353
+ expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
354
+ before,
355
+ 5,
356
+ );
357
+ });
358
+
341
359
  it("follows the prop again once the store catches up", () => {
342
360
  const { container, rerender } = renderRerenderable(
343
361
  <TimelineAutomationLane {...laneProps({ automation: ramp })} />,
@@ -359,6 +377,25 @@ describe("TimelineAutomationLane", () => {
359
377
  expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
360
378
  });
361
379
 
380
+ it("commits the dragged points when an older save lands mid-drag", () => {
381
+ const onCommit = vi.fn(async (_next: HfAutomation) => ({ status: "saved" as const }));
382
+ const { container, rerender } = renderRerenderable(
383
+ <TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
384
+ );
385
+ const svg = container.querySelector("svg")!;
386
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
387
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
388
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
389
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
390
+ const older = onCommit.mock.calls[0]![0];
391
+ fire(svg, "pointerdown", { clientX: 160, clientY: 40 });
392
+ fire(svg, "pointermove", { clientX: 320, clientY: 40 });
393
+ rerender(<TimelineAutomationLane {...laneProps({ automation: older, onCommit })} />);
394
+ fire(svg, "pointerup", { clientX: 320, clientY: 40 });
395
+ const released = onCommit.mock.calls[1]![0];
396
+ expect(released.lanes[0]!.points[0]!.t).toBeGreaterThan(older.lanes[0]!.points[0]!.t + 1);
397
+ });
398
+
362
399
  it("keeps lane order when editing, so the view does not switch parameters", () => {
363
400
  // The displayed lane defaults to the first one. Moving the edited lane to
364
401
  // the end of the list swapped the lane out from under the pointer on the
@@ -1627,6 +1664,7 @@ describe("TimelineAutomationLane stretch", () => {
1627
1664
  const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
1628
1665
  ?.points;
1629
1666
  expect(reverted).toEqual(stretchable.lanes[0]?.points);
1667
+ expect(props.onPreview.mock.calls.at(-1)?.[1]).toBe(true);
1630
1668
  expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
1631
1669
  });
1632
1670