@hyperframes/studio 0.7.63 → 0.7.65

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 (38) hide show
  1. package/dist/assets/{hyperframes-player-BO2UXx-E.js → hyperframes-player-D2zuA3cx.js} +1 -1
  2. package/dist/assets/{index-C_7k3kwr.js → index-BqX4YLnH.js} +190 -190
  3. package/dist/assets/{index-_72wCKSP.js → index-DwwsYjuk.js} +1 -1
  4. package/dist/assets/{index-C0A0DlLk.js → index-hLCqiyjx.js} +1 -1
  5. package/dist/chunk-PZ5AY32C.js +10 -0
  6. package/dist/{domEditingLayers-2CKWGEHS.js → domEditingLayers-AT7G6F4L.js} +2 -1
  7. package/dist/domEditingLayers-AT7G6F4L.js.map +1 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +171 -53
  10. package/dist/index.js.map +1 -1
  11. package/dist/styles/tailwind-preset.js +2 -0
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +9 -8
  14. package/src/components/StudioRightPanel.tsx +2 -2
  15. package/src/components/editor/keyframeRetime.test.ts +56 -17
  16. package/src/components/editor/keyframeRetime.ts +48 -4
  17. package/src/components/editor/manualEditingAvailability.ts +7 -0
  18. package/src/components/editor/manualEditsTypes.ts +1 -1
  19. package/src/components/nle/useTimelineEditCallbacks.test.ts +85 -0
  20. package/src/components/nle/useTimelineEditCallbacks.ts +52 -12
  21. package/src/components/panels/VariablesPanel.tsx +3 -1
  22. package/src/contexts/FileManagerContext.tsx +1 -0
  23. package/src/hooks/timelineEditingHelpers.test.ts +26 -0
  24. package/src/hooks/timelineEditingHelpers.ts +10 -1
  25. package/src/hooks/useGsapKeyframeOps.test.tsx +1 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +2 -0
  27. package/src/hooks/useGsapPropertyDebounce.ts +1 -0
  28. package/src/hooks/useRazorSplit.ts +1 -0
  29. package/src/hooks/useTimelineEditing.test.tsx +1 -0
  30. package/src/hooks/useTimelineGroupEditing.ts +2 -0
  31. package/src/player/index.ts +0 -2
  32. package/src/utils/sdkCutover.familyGate.test.ts +47 -0
  33. package/src/utils/sdkCutover.test.ts +1 -0
  34. package/src/utils/sdkCutover.ts +95 -38
  35. package/src/utils/sdkCutoverPolicy.report.ts +3 -0
  36. package/src/utils/sdkCutoverPolicy.test.ts +38 -0
  37. package/src/utils/sdkCutoverPolicy.ts +98 -0
  38. /package/dist/{domEditingLayers-2CKWGEHS.js.map → chunk-PZ5AY32C.js.map} +0 -0
@@ -1,3 +1,5 @@
1
+ import "../chunk-PZ5AY32C.js";
2
+
1
3
  // src/styles/tailwind-preset.shared.js
2
4
  var studioPreset = {
3
5
  theme: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/styles/tailwind-preset.shared.js","../../src/styles/tailwind-preset.ts"],"sourcesContent":["const studioPreset = {\n theme: {\n extend: {\n colors: {\n studio: {\n bg: \"#0a0a0a\",\n surface: \"#141414\",\n border: \"#262626\",\n text: \"#e5e5e5\",\n muted: \"#737373\",\n accent: \"#3CE6AC\",\n },\n panel: {\n bg: \"#0C0C0E\",\n // Open inspector-section body — slightly lighter than headers (bg)\n // so the recessed scrollable region reads distinct.\n \"bg-inset\": \"#121214\",\n input: \"#161618\",\n surface: \"#18181B\",\n hover: \"#27272A\",\n border: \"#1E1E1E\",\n \"border-input\": \"#27272A\",\n hairline: \"#1A1A1C\",\n \"text-0\": \"#FAFAFA\",\n \"text-1\": \"#E4E4E7\",\n \"text-2\": \"#A1A1AA\",\n \"text-3\": \"#71717A\",\n \"text-4\": \"#52525B\",\n \"text-5\": \"#3F3F46\",\n accent: \"#3CE6AC\",\n danger: \"#EF4444\",\n media: \"#00E3FF\",\n container: \"#F5A623\",\n },\n },\n },\n },\n plugins: [],\n};\n\nexport default studioPreset;\n","import studioPreset from \"./tailwind-preset.shared.js\";\n\nexport default studioPreset;\n"],"mappings":";AAAA,IAAM,eAAe;AAAA,EACnB,OAAO;AAAA,IACL,QAAQ;AAAA,MACN,QAAQ;AAAA,QACN,QAAQ;AAAA,UACN,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,MAAM;AAAA,UACN,OAAO;AAAA,UACP,QAAQ;AAAA,QACV;AAAA,QACA,OAAO;AAAA,UACL,IAAI;AAAA;AAAA;AAAA,UAGJ,YAAY;AAAA,UACZ,OAAO;AAAA,UACP,SAAS;AAAA,UACT,OAAO;AAAA,UACP,QAAQ;AAAA,UACR,gBAAgB;AAAA,UAChB,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,OAAO;AAAA,UACP,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC;AACZ;AAEA,IAAO,iCAAQ;;;ACtCf,IAAO,0BAAQ;","names":[]}
1
+ {"version":3,"sources":["../../src/styles/tailwind-preset.shared.js","../../src/styles/tailwind-preset.ts"],"sourcesContent":["const studioPreset = {\n theme: {\n extend: {\n colors: {\n studio: {\n bg: \"#0a0a0a\",\n surface: \"#141414\",\n border: \"#262626\",\n text: \"#e5e5e5\",\n muted: \"#737373\",\n accent: \"#3CE6AC\",\n },\n panel: {\n bg: \"#0C0C0E\",\n // Open inspector-section body — slightly lighter than headers (bg)\n // so the recessed scrollable region reads distinct.\n \"bg-inset\": \"#121214\",\n input: \"#161618\",\n surface: \"#18181B\",\n hover: \"#27272A\",\n border: \"#1E1E1E\",\n \"border-input\": \"#27272A\",\n hairline: \"#1A1A1C\",\n \"text-0\": \"#FAFAFA\",\n \"text-1\": \"#E4E4E7\",\n \"text-2\": \"#A1A1AA\",\n \"text-3\": \"#71717A\",\n \"text-4\": \"#52525B\",\n \"text-5\": \"#3F3F46\",\n accent: \"#3CE6AC\",\n danger: \"#EF4444\",\n media: \"#00E3FF\",\n container: \"#F5A623\",\n },\n },\n },\n },\n plugins: [],\n};\n\nexport default studioPreset;\n","import studioPreset from \"./tailwind-preset.shared.js\";\n\nexport default studioPreset;\n"],"mappings":";;;AAAA,IAAM,eAAe;AAAA,EACnB,OAAO;AAAA,IACL,QAAQ;AAAA,MACN,QAAQ;AAAA,QACN,QAAQ;AAAA,UACN,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,MAAM;AAAA,UACN,OAAO;AAAA,UACP,QAAQ;AAAA,QACV;AAAA,QACA,OAAO;AAAA,UACL,IAAI;AAAA;AAAA;AAAA,UAGJ,YAAY;AAAA,UACZ,OAAO;AAAA,UACP,SAAS;AAAA,UACT,OAAO;AAAA,UACP,QAAQ;AAAA,UACR,gBAAgB;AAAA,UAChB,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,UAAU;AAAA,UACV,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,OAAO;AAAA,UACP,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC;AACZ;AAEA,IAAO,iCAAQ;;;ACtCf,IAAO,0BAAQ;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.63",
3
+ "version": "0.7.65",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.63",
50
- "@hyperframes/parsers": "0.7.63",
51
- "@hyperframes/player": "0.7.63",
52
- "@hyperframes/studio-server": "0.7.63",
53
- "@hyperframes/sdk": "0.7.63"
49
+ "@hyperframes/parsers": "0.7.65",
50
+ "@hyperframes/player": "0.7.65",
51
+ "@hyperframes/core": "0.7.65",
52
+ "@hyperframes/studio-server": "0.7.65",
53
+ "@hyperframes/sdk": "0.7.65"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.63"
68
+ "@hyperframes/producer": "0.7.65"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -77,6 +77,7 @@
77
77
  "build": "vite build && tsup",
78
78
  "typecheck": "tsc --noEmit",
79
79
  "test": "vitest run",
80
- "test:watch": "vitest"
80
+ "test:watch": "vitest",
81
+ "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
81
82
  }
82
83
  }
@@ -5,7 +5,7 @@ import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPane
5
5
  import { BlockParamsPanel } from "./editor/BlockParamsPanel";
6
6
  import { RenderQueue } from "./renders/RenderQueue";
7
7
  import { SlideshowPanel } from "./panels/SlideshowPanel";
8
- import { VariablesPanel } from "./panels/VariablesPanel";
8
+ import { VariablesPanel, type StudioEditPersistenceProps } from "./panels/VariablesPanel";
9
9
  import { PanelTabButton } from "./PanelTabButton";
10
10
  import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
11
11
  import type { RenderJob } from "./renders/useRenderQueue";
@@ -35,7 +35,7 @@ import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
35
35
  import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
36
36
  import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
37
37
 
38
- export interface StudioRightPanelProps {
38
+ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
39
39
  designPanelActive: boolean;
40
40
  activeBlockParams?: {
41
41
  blockName: string;
@@ -9,6 +9,7 @@ const KEYFRAMES: RetimeKeyframe[] = [
9
9
  { percentage: 100, properties: { x: 100 }, ease: "power2.in" },
10
10
  ];
11
11
  const WINDOW = { tweenStart: 2, tweenDuration: 4 };
12
+ const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
12
13
 
13
14
  describe("resolveKeyframeRetime — move (within the tween window)", () => {
14
15
  it("re-keys an interior keyframe to the tween-% of the drop", () => {
@@ -32,15 +33,53 @@ describe("resolveKeyframeRetime — move (within the tween window)", () => {
32
33
  expect(r.kind).toBe("noop");
33
34
  });
34
35
 
35
- it("moves a flat (keyframe-less) tween without needing the keyframes array", () => {
36
+ it("shortens a flat tween when its synthesized end diamond moves left", () => {
36
37
  const r = resolveKeyframeRetime({
37
38
  ...WINDOW,
38
39
  keyframes: [],
39
40
  draggedTweenPct: 100,
40
- dropAbsTime: 5, // (5-2)/4 = 75%
41
+ dropAbsTime: 5,
41
42
  });
42
- expect(r.kind).toBe("move");
43
- expect(r.toTweenPct).toBeCloseTo(75, 5);
43
+ expect(r.kind).toBe("resize");
44
+ expect(r.position).toBe(2);
45
+ expect(r.duration).toBe(3);
46
+ expect(r.pctRemap).toEqual([]);
47
+ });
48
+
49
+ it("moves a flat tween's start while preserving its absolute end", () => {
50
+ const r = resolveKeyframeRetime({
51
+ ...WINDOW,
52
+ keyframes: [],
53
+ draggedTweenPct: 0,
54
+ dropAbsTime: 3,
55
+ });
56
+ expect(r.kind).toBe("resize");
57
+ expect(r.position).toBe(3);
58
+ expect(r.duration).toBe(3);
59
+ expect(r.pctRemap).toEqual([]);
60
+ });
61
+
62
+ it("no-ops an unexpected interior percentage on a flat tween", () => {
63
+ expect(
64
+ resolveKeyframeRetime({
65
+ ...WINDOW,
66
+ keyframes: [],
67
+ draggedTweenPct: 50,
68
+ dropAbsTime: 5,
69
+ }).kind,
70
+ ).toBe("noop");
71
+ });
72
+
73
+ it("no-ops instead of rounding a sub-10ms flat tween to zero", () => {
74
+ expect(
75
+ resolveKeyframeRetime({
76
+ tweenStart: 2,
77
+ tweenDuration: 4,
78
+ keyframes: [],
79
+ draggedTweenPct: 100,
80
+ dropAbsTime: 2.0004,
81
+ }).kind,
82
+ ).toBe("noop");
44
83
  });
45
84
  });
46
85
 
@@ -67,10 +106,9 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
67
106
 
68
107
  it("extends the FIRST keyframe before the start, shifting position earlier", () => {
69
108
  const r = resolveKeyframeRetime({
70
- ...WINDOW,
109
+ ...LEFT_BOUNDARY_DROP,
71
110
  keyframes: KEYFRAMES,
72
111
  draggedTweenPct: 0,
73
- dropAbsTime: 0.5, // before start (2) → move position back + grow duration
74
112
  });
75
113
  expect(r.kind).toBe("resize");
76
114
  expect(r.position).toBeCloseTo(0.5, 5);
@@ -102,10 +140,9 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
102
140
 
103
141
  it("resizes left before the start", () => {
104
142
  const r = resolveKeyframeRetime({
105
- ...WINDOW,
143
+ ...LEFT_BOUNDARY_DROP,
106
144
  keyframes: lone,
107
145
  draggedTweenPct: 100,
108
- dropAbsTime: 0.5,
109
146
  });
110
147
  expect(r.kind).toBe("resize");
111
148
  expect(r.position).toBeCloseTo(0.5, 5);
@@ -127,14 +164,16 @@ describe("resolveKeyframeRetime — guards", () => {
127
164
  ).toBe("noop");
128
165
  });
129
166
 
130
- it("no-ops a boundary drop on a flat tween (nothing to remap)", () => {
131
- expect(
132
- resolveKeyframeRetime({
133
- ...WINDOW,
134
- keyframes: [],
135
- draggedTweenPct: 100,
136
- dropAbsTime: 8,
137
- }).kind,
138
- ).toBe("noop");
167
+ it("extends a flat tween when its synthesized end diamond moves right", () => {
168
+ const r = resolveKeyframeRetime({
169
+ ...WINDOW,
170
+ keyframes: [],
171
+ draggedTweenPct: 100,
172
+ dropAbsTime: 8,
173
+ });
174
+ expect(r.kind).toBe("resize");
175
+ expect(r.position).toBe(2);
176
+ expect(r.duration).toBe(6);
177
+ expect(r.pctRemap).toEqual([]);
139
178
  });
140
179
  });
@@ -51,11 +51,40 @@ export interface KeyframeRetimeResult {
51
51
  const NOOP_EPSILON_PCT = 0.1;
52
52
  /** Slack (seconds) for the within-tween boundary test. */
53
53
  const EPSILON_TIME = 1e-4;
54
+ /** Smallest authored tween window; avoids sub-millisecond/round-to-zero durations. */
55
+ const MIN_TWEEN_DURATION = 0.01;
54
56
 
55
57
  const round3 = (n: number) => Math.round(n * 1000) / 1000;
56
58
  const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
57
59
  const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
58
60
 
61
+ /** Resolve timing for a flat tween's synthesized start/end diamond. */
62
+ function resolveFlatTweenBoundaryRetime(opts: {
63
+ keyframeCount: number;
64
+ draggedTweenPct: number;
65
+ tweenStart: number;
66
+ tweenEnd: number;
67
+ dropAbsTime: number;
68
+ }): KeyframeRetimeResult | null {
69
+ const { keyframeCount, draggedTweenPct, tweenStart, tweenEnd, dropAbsTime } = opts;
70
+ if (keyframeCount > 0) return null;
71
+ // Flat tweens have only synthesized boundary diamonds. Never delegate an
72
+ // unexpected interior percentage to the authored-keyframe move path.
73
+ if (draggedTweenPct !== 0 && draggedTweenPct !== 100) return { kind: "noop" };
74
+ const draggedTime = draggedTweenPct === 0 ? tweenStart : tweenEnd;
75
+ if (Math.abs(dropAbsTime - draggedTime) <= EPSILON_TIME) return { kind: "noop" };
76
+ const newStart = draggedTweenPct === 0 ? dropAbsTime : tweenStart;
77
+ const newEnd = draggedTweenPct === 100 ? dropAbsTime : tweenEnd;
78
+ const newDuration = newEnd - newStart;
79
+ if (newDuration < MIN_TWEEN_DURATION) return { kind: "noop" };
80
+ return {
81
+ kind: "resize",
82
+ position: round3(newStart),
83
+ duration: round3(newDuration),
84
+ pctRemap: [],
85
+ };
86
+ }
87
+
59
88
  /**
60
89
  * Decide move vs resize for a dragged keyframe and, for resize, return the new
61
90
  * tween window + remapped keyframes.
@@ -76,20 +105,35 @@ export function resolveKeyframeRetime(opts: {
76
105
  if (tweenDuration <= 0) return { kind: "noop" };
77
106
  const tweenEnd = tweenStart + tweenDuration;
78
107
 
79
- // Within the tween window plain move (re-key the tween-%). This branch never
80
- // touches the keyframes array, so it still works for synthesized flat tweens.
108
+ // A flat tween's synthesized diamonds are its real start/end boundaries
109
+ // there is no authored keyframe node to re-key. Moving either boundary must
110
+ // therefore resize the tween window while keeping the opposite endpoint at
111
+ // its absolute time. Returning `resize` with an empty remap lets the caller
112
+ // update the flat tween's position/duration instead of sending a keyframe
113
+ // mutation that would silently no-op.
114
+ const flatBoundary = resolveFlatTweenBoundaryRetime({
115
+ keyframeCount: keyframes.length,
116
+ draggedTweenPct,
117
+ tweenStart,
118
+ tweenEnd,
119
+ dropAbsTime,
120
+ });
121
+ if (flatBoundary) return flatBoundary;
122
+
123
+ // Within the tween window → plain move (re-key the tween-%).
81
124
  if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
82
125
  const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
83
126
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
84
127
  return { kind: "move", toTweenPct };
85
128
  }
86
129
 
87
- // Boundary resize needs the real keyframes to remap; a flat tween has none here.
130
+ // Boundary resize needs the real keyframes to remap. Flat start/end boundaries
131
+ // were handled above.
88
132
  if (keyframes.length === 0) return { kind: "noop" };
89
133
 
90
134
  const newStart = Math.min(dropAbsTime, tweenStart);
91
135
  const newEnd = Math.max(dropAbsTime, tweenEnd);
92
- const newDuration = Math.max(0.01, newEnd - newStart);
136
+ const newDuration = Math.max(MIN_TWEEN_DURATION, newEnd - newStart);
93
137
 
94
138
  // The dragged keyframe is the one whose tween-% is closest to draggedTweenPct.
95
139
  let draggedIdx = 0;
@@ -87,6 +87,12 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
87
87
  false,
88
88
  );
89
89
 
90
+ /** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
91
+ export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
92
+ env,
93
+ STUDIO_SDK_CUTOVER_ENABLED,
94
+ );
95
+
90
96
  // Resolver-parity tripwire (telemetry-only, decoupled from cutover).
91
97
  // Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
92
98
  // divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
@@ -109,3 +115,4 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
109
115
  );
110
116
 
111
117
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
118
+ import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
@@ -5,7 +5,7 @@ export {
5
5
  STUDIO_WIDTH_PROP,
6
6
  STUDIO_HEIGHT_PROP,
7
7
  STUDIO_MANUAL_EDIT_GESTURE_ATTR,
8
- } from "@hyperframes/studio-server/draft-markers";
8
+ } from "@hyperframes/core/editing/draft-markers";
9
9
  export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
10
10
 
11
11
  /* ── Internal DOM attribute names ─────────────────────────────────── */
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveTimelineKeyframeTarget } from "./useTimelineEditCallbacks";
3
+
4
+ const FLAT_ANIMATION = {
5
+ id: "#title-fromTo-2900",
6
+ propertyGroup: null,
7
+ };
8
+
9
+ describe("resolveTimelineKeyframeTarget", () => {
10
+ it("resolves a synthesized diamond when one mixed flat tween is selected", () => {
11
+ expect(
12
+ resolveTimelineKeyframeTarget(
13
+ 8.75,
14
+ [{ percentage: 8.75, tweenPercentage: 100 }],
15
+ [FLAT_ANIMATION],
16
+ ),
17
+ ).toEqual({ animId: "#title-fromTo-2900", tweenPct: 100 });
18
+ });
19
+
20
+ it("keeps multiple ungrouped flat tweens unresolved instead of guessing", () => {
21
+ expect(
22
+ resolveTimelineKeyframeTarget(
23
+ 8.75,
24
+ [{ percentage: 8.75, tweenPercentage: 100 }],
25
+ [FLAT_ANIMATION, { id: "#title-to-2900", propertyGroup: null }],
26
+ ),
27
+ ).toBeNull();
28
+ });
29
+
30
+ it("keeps multiple flat tweens in the same property group unresolved", () => {
31
+ expect(
32
+ resolveTimelineKeyframeTarget(
33
+ 50,
34
+ [{ percentage: 50, tweenPercentage: 100, propertyGroup: "position" }],
35
+ [
36
+ { id: "position-a", propertyGroup: "position" },
37
+ { id: "position-b", propertyGroup: "position" },
38
+ ],
39
+ ),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it("keeps a keyframed and flat tween in the same property group unresolved", () => {
44
+ expect(
45
+ resolveTimelineKeyframeTarget(
46
+ 50,
47
+ [{ percentage: 50, tweenPercentage: 25, propertyGroup: "position" }],
48
+ [
49
+ { id: "position-keyframed", propertyGroup: "position", keyframes: {} },
50
+ { id: "position-flat", propertyGroup: "position" },
51
+ ],
52
+ ),
53
+ ).toBeNull();
54
+ });
55
+
56
+ it("keeps multiple ungrouped keyframed tweens unresolved", () => {
57
+ expect(
58
+ resolveTimelineKeyframeTarget(
59
+ 50,
60
+ [{ percentage: 50, tweenPercentage: 25 }],
61
+ [
62
+ { id: "ungrouped-a", keyframes: {} },
63
+ { id: "ungrouped-b", keyframes: {} },
64
+ ],
65
+ ),
66
+ ).toBeNull();
67
+ });
68
+
69
+ it("does not infer an animation when the rendered diamond misses the cache", () => {
70
+ expect(resolveTimelineKeyframeTarget(60, [], [FLAT_ANIMATION])).toBeNull();
71
+ });
72
+
73
+ it("resolves the sole candidate in an explicit property group", () => {
74
+ expect(
75
+ resolveTimelineKeyframeTarget(
76
+ 50,
77
+ [{ percentage: 50, tweenPercentage: 25, propertyGroup: "position" }],
78
+ [
79
+ { id: "opacity", propertyGroup: "opacity" },
80
+ { id: "position", propertyGroup: "position" },
81
+ ],
82
+ ),
83
+ ).toEqual({ animId: "position", tweenPct: 25 });
84
+ });
85
+ });
@@ -36,6 +36,41 @@ export interface TimelineEditCallbackDeps {
36
36
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
37
37
  }
38
38
 
39
+ interface TimelineKeyframeTargetAnimation {
40
+ id: string;
41
+ propertyGroup?: string | null;
42
+ keyframes?: unknown;
43
+ }
44
+
45
+ interface TimelineCachedKeyframe {
46
+ percentage: number;
47
+ tweenPercentage?: number;
48
+ propertyGroup?: string;
49
+ }
50
+
51
+ /**
52
+ * Resolve a rendered timeline diamond back to the animation that authored it.
53
+ * Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
54
+ * a property group nor real keyframes. The cache currently carries a property
55
+ * group, not an animation id, so resolution is safe only when that group has a
56
+ * single candidate. Ambiguous candidates remain unresolved rather than
57
+ * retiming an arbitrary tween.
58
+ */
59
+ export function resolveTimelineKeyframeTarget(
60
+ pct: number,
61
+ keyframes: ReadonlyArray<TimelineCachedKeyframe>,
62
+ animations: ReadonlyArray<TimelineKeyframeTargetAnimation>,
63
+ ): { animId: string; tweenPct: number } | null {
64
+ const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
65
+ if (!kf) return null;
66
+ const group = kf?.propertyGroup;
67
+ const candidates = group
68
+ ? animations.filter((animation) => animation.propertyGroup === group)
69
+ : animations.filter((animation) => !animation.propertyGroup);
70
+ const animation = candidates.length === 1 ? candidates[0] : undefined;
71
+ return animation ? { animId: animation.id, tweenPct: kf.tweenPercentage ?? pct } : null;
72
+ }
73
+
39
74
  /**
40
75
  * Builds the timeline edit callback bag (move/resize/split/razor plus the
41
76
  * keyframe-diamond callbacks) provided to `<Timeline>` via TimelineEditProvider.
@@ -76,12 +111,7 @@ export function useTimelineEditCallbacks({
76
111
  // fallow-ignore-next-line complexity
77
112
  (pct: number): { animId: string; tweenPct: number } | null => {
78
113
  const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
79
- const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
80
- const group = kf?.propertyGroup;
81
- const anim =
82
- (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
83
- selectedGsapAnimations.find((a) => a.keyframes);
84
- return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null;
114
+ return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
85
115
  },
86
116
  [domEditSelection?.id, selectedGsapAnimations],
87
117
  );
@@ -154,12 +184,22 @@ export function useTimelineEditCallbacks({
154
184
  decision.position != null &&
155
185
  decision.duration != null
156
186
  ) {
157
- handleGsapResizeKeyframedTween(
158
- target.animId,
159
- decision.position,
160
- decision.duration,
161
- decision.pctRemap,
162
- );
187
+ if (anim.keyframes) {
188
+ handleGsapResizeKeyframedTween(
189
+ target.animId,
190
+ decision.position,
191
+ decision.duration,
192
+ decision.pctRemap,
193
+ );
194
+ } else {
195
+ // resize-keyframed-tween requires an authored `keyframes` AST node
196
+ // and intentionally no-ops for a flat tween. Update its real tween
197
+ // window through the metadata writer (and SDK cutover path) instead.
198
+ handleGsapUpdateMeta(target.animId, {
199
+ position: decision.position,
200
+ duration: decision.duration,
201
+ });
202
+ }
163
203
  }
164
204
  },
165
205
  onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
@@ -31,7 +31,7 @@ function shellSingleQuote(value: string): string {
31
31
  return `'${value.replace(/'/g, `'\\''`)}'`;
32
32
  }
33
33
 
34
- interface VariablesPanelProps {
34
+ export interface StudioEditPersistenceProps {
35
35
  sdkSession: Composition | null;
36
36
  publishSdkSession: PublishSdkSession;
37
37
  reloadPreview: () => void;
@@ -43,6 +43,8 @@ interface VariablesPanelProps {
43
43
  }) => Promise<void>;
44
44
  }
45
45
 
46
+ type VariablesPanelProps = StudioEditPersistenceProps;
47
+
46
48
  function formatIssue(issue: VariableValidationIssue): string {
47
49
  switch (issue.kind) {
48
50
  case "undeclared":
@@ -17,6 +17,7 @@ export function useFileManagerContextOptional(): FileManagerValue | null {
17
17
 
18
18
  export function FileManagerProvider({
19
19
  value: {
20
+ // fallow-ignore-next-line code-duplication
20
21
  editingFile,
21
22
  setEditingFile,
22
23
  projectDir,
@@ -142,6 +142,32 @@ describe("applyTimelineStackingReorder", () => {
142
142
  });
143
143
 
144
144
  describe("patchIframeDomTiming", () => {
145
+ it("patches a top-level composition host in its parent source file", () => {
146
+ const iframe = makeIframeWith(`
147
+ <div data-composition-id="root">
148
+ <div
149
+ id="scene-host"
150
+ data-composition-id="scene"
151
+ data-composition-src="compositions/scene.html"
152
+ data-start="2"
153
+ ></div>
154
+ </div>
155
+ `);
156
+ const host = iframe.contentDocument?.getElementById("scene-host");
157
+ const target = el({
158
+ id: "scene-host",
159
+ tag: "div",
160
+ kind: "composition",
161
+ domId: "scene-host",
162
+ sourceFile: "index.html",
163
+ compositionSrc: "compositions/scene.html",
164
+ });
165
+
166
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
167
+
168
+ expect(host?.getAttribute("data-start")).toBe("9");
169
+ });
170
+
145
171
  it("resolves selectorIndex within the element's source file", () => {
146
172
  const iframe = makeIframeWith(`
147
173
  <div data-composition-id="root">
@@ -13,7 +13,10 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
13
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
14
14
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
15
  import { readFileContent } from "./timelineTimingSync";
16
- import { findElementForSelection } from "../components/editor/domEditingElement";
16
+ import {
17
+ findElementForSelection,
18
+ findElementForTimelineElement,
19
+ } from "../components/editor/domEditingElement";
17
20
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
18
21
  export { readFileContent };
19
22
  function isHTMLElement(element: Element | null): element is HTMLElement {
@@ -137,6 +140,12 @@ export function findTimelineElementInIframe(
137
140
  try {
138
141
  const doc = iframe?.contentDocument;
139
142
  if (!doc) return null;
143
+ if (element.kind === "composition" && element.compositionSrc) {
144
+ return findElementForTimelineElement(doc, element, {
145
+ activeCompositionPath,
146
+ isMasterView: true,
147
+ });
148
+ }
140
149
  return findElementForSelection(
141
150
  doc,
142
151
  {
@@ -25,6 +25,7 @@ function renderKeyframeOps(over: {
25
25
  }) {
26
26
  const captured: { api: HookApi | null } = { api: null };
27
27
  function Probe() {
28
+ // fallow-ignore-next-line code-duplication
28
29
  captured.api = useGsapKeyframeOps({
29
30
  activeCompPath: "index.html",
30
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
@@ -1,3 +1,5 @@
1
+ // fallow-ignore-file code-duplication
2
+ // Add/remove operation-family transaction shapes stay parallel until SDK graduation.
1
3
  import { useCallback } from "react";
2
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
5
  import type { Composition } from "@hyperframes/sdk";
@@ -84,6 +84,7 @@ export function useGsapPropertyDebounce(
84
84
  const mutation = { type: "update-property", animationId, property, value };
85
85
  const label = `Edit GSAP ${property}`;
86
86
  try {
87
+ // fallow-ignore-next-line code-duplication
87
88
  const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
88
89
  if (sdkSession && sdkDeps) {
89
90
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -9,6 +9,7 @@ import type { RecordEditInput } from "./timelineEditingHelpers";
9
9
 
10
10
  interface UseRazorSplitOptions {
11
11
  projectId: string | null;
12
+ // fallow-ignore-next-line code-duplication
12
13
  activeCompPath: string | null;
13
14
  showToast: (message: string, tone?: "error" | "info") => void;
14
15
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
 
3
4
  import React, { act, useRef } from "react";
@@ -1,3 +1,5 @@
1
+ // fallow-ignore-file code-duplication
2
+ // Move/resize operation families remain parallel until SDK graduation.
1
3
  import { useCallback, type MutableRefObject, type RefObject } from "react";
2
4
  import type { Composition } from "@hyperframes/sdk";
3
5
  import type { TimelineElement } from "../player";
@@ -11,8 +11,6 @@ export { resolveIframe } from "./lib/timelineDOM";
11
11
 
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
- // Public library surface; external consumers are invisible to the workspace analyzer.
15
- // fallow-ignore-next-line unused-exports
16
14
  export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
17
15
 
18
16
  // Utils