@hyperframes/studio 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
  3. package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2699 -2052
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/marqueeCommit.ts +63 -20
  18. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  19. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  20. package/src/components/editor/transform3dProjection.test.ts +99 -0
  21. package/src/components/editor/transform3dProjection.ts +172 -0
  22. package/src/components/sidebar/AssetsTab.tsx +23 -213
  23. package/src/components/sidebar/AudioRow.tsx +202 -0
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/hooks/gsapDragCommit.test.ts +9 -5
  26. package/src/hooks/gsapDragCommit.ts +28 -9
  27. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  28. package/src/hooks/gsapRuntimePatch.ts +162 -35
  29. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  30. package/src/hooks/useDomEditCommits.ts +0 -2
  31. package/src/hooks/useDomEditSession.ts +4 -2
  32. package/src/hooks/useDomGeometryCommits.ts +3 -31
  33. package/src/hooks/useGsapAwareEditing.ts +31 -1
  34. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  35. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  36. package/src/hooks/useGsapTweenCache.ts +6 -4
  37. package/src/utils/marqueeGeometry.test.ts +15 -98
  38. package/src/utils/marqueeGeometry.ts +1 -158
  39. package/dist/assets/index-BSkUuN8g.css +0 -1
@@ -0,0 +1,202 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { ContextMenu } from "./AssetContextMenu";
3
+ import { basename, getAudioSubtype } from "./assetHelpers";
4
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
+
6
+ export function AudioRow({
7
+ projectId,
8
+ asset,
9
+ used,
10
+ meta,
11
+ onCopy,
12
+ isCopied,
13
+ onDelete,
14
+ onRename,
15
+ }: {
16
+ projectId: string;
17
+ asset: string;
18
+ used: boolean;
19
+ meta?: { description?: string; duration?: number };
20
+ onCopy: (path: string) => void;
21
+ isCopied: boolean;
22
+ onDelete?: (path: string) => void;
23
+ onRename?: (oldPath: string, newPath: string) => void;
24
+ }) {
25
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
26
+ const [playing, setPlaying] = useState(false);
27
+ const [bars, setBars] = useState<number[]>([]);
28
+ const audioRef = useRef<HTMLAudioElement | null>(null);
29
+ const actxRef = useRef<AudioContext | null>(null);
30
+ const analyserRef = useRef<AnalyserNode | null>(null);
31
+ const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
32
+ const animRef = useRef<number>(0);
33
+ const name = basename(asset);
34
+ const subtype = getAudioSubtype(asset);
35
+ const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
36
+
37
+ useEffect(() => {
38
+ return () => {
39
+ cancelAnimationFrame(animRef.current);
40
+ audioRef.current?.pause();
41
+ actxRef.current?.close();
42
+ };
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ if (playing) {
47
+ const barCount = 24;
48
+ const loop = () => {
49
+ const analyser = analyserRef.current;
50
+ if (!analyser) {
51
+ animRef.current = requestAnimationFrame(loop);
52
+ return;
53
+ }
54
+ const data = new Uint8Array(analyser.frequencyBinCount);
55
+ analyser.getByteFrequencyData(data);
56
+ const step = Math.floor(data.length / barCount);
57
+ const next: number[] = [];
58
+ for (let i = 0; i < barCount; i++) {
59
+ let sum = 0;
60
+ for (let j = 0; j < step; j++) sum += data[i * step + j];
61
+ next.push(sum / step / 255);
62
+ }
63
+ setBars(next);
64
+ if (audioRef.current && !audioRef.current.paused)
65
+ animRef.current = requestAnimationFrame(loop);
66
+ };
67
+ animRef.current = requestAnimationFrame(loop);
68
+ } else {
69
+ setBars([]);
70
+ }
71
+ return () => cancelAnimationFrame(animRef.current);
72
+ }, [playing]);
73
+
74
+ const togglePlay = useCallback(async () => {
75
+ if (playing) {
76
+ audioRef.current?.pause();
77
+ setPlaying(false);
78
+ cancelAnimationFrame(animRef.current);
79
+ return;
80
+ }
81
+
82
+ if (!actxRef.current) {
83
+ actxRef.current = new AudioContext();
84
+ analyserRef.current = actxRef.current.createAnalyser();
85
+ analyserRef.current.fftSize = 256;
86
+ analyserRef.current.smoothingTimeConstant = 0.7;
87
+ }
88
+
89
+ if (!audioRef.current) {
90
+ const el = new Audio();
91
+ el.onended = () => {
92
+ setPlaying(false);
93
+ cancelAnimationFrame(animRef.current);
94
+ };
95
+ audioRef.current = el;
96
+ sourceRef.current = actxRef.current.createMediaElementSource(el);
97
+ sourceRef.current.connect(analyserRef.current!);
98
+ analyserRef.current!.connect(actxRef.current.destination);
99
+ el.src = serveUrl;
100
+ }
101
+
102
+ if (actxRef.current.state === "suspended") await actxRef.current.resume();
103
+ audioRef.current.currentTime = 0;
104
+ await audioRef.current.play();
105
+ setPlaying(true);
106
+ }, [serveUrl, playing]);
107
+
108
+ return (
109
+ <>
110
+ <div
111
+ draggable
112
+ onClick={() => onCopy(asset)}
113
+ onDragStart={(e) => {
114
+ e.dataTransfer.effectAllowed = "copy";
115
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
116
+ e.dataTransfer.setData("text/plain", asset);
117
+ }}
118
+ onContextMenu={(e) => {
119
+ e.preventDefault();
120
+ setContextMenu({ x: e.clientX, y: e.clientY });
121
+ }}
122
+ className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
123
+ playing
124
+ ? "bg-panel-accent/[0.06]"
125
+ : isCopied
126
+ ? "bg-panel-accent/10"
127
+ : "hover:bg-panel-surface-hover"
128
+ }`}
129
+ >
130
+ <button
131
+ className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
132
+ playing
133
+ ? "bg-panel-accent/15 text-panel-accent"
134
+ : "text-panel-text-5 group-hover:text-panel-text-3"
135
+ }`}
136
+ onClick={(e) => {
137
+ e.stopPropagation();
138
+ togglePlay();
139
+ }}
140
+ >
141
+ {playing ? (
142
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
143
+ <rect x="6" y="4" width="4" height="16" rx="1" />
144
+ <rect x="14" y="4" width="4" height="16" rx="1" />
145
+ </svg>
146
+ ) : (
147
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
148
+ <polygon points="6,4 20,12 6,20" />
149
+ </svg>
150
+ )}
151
+ </button>
152
+ <div className="min-w-0 flex-1">
153
+ <div className="flex items-center gap-1.5">
154
+ <span
155
+ className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
156
+ >
157
+ {name}
158
+ </span>
159
+ {!playing && (
160
+ <span className="text-[11px] text-panel-text-5 flex-shrink-0">
161
+ {meta?.duration ? `${meta.duration}s · ` : ""}
162
+ {subtype}
163
+ </span>
164
+ )}
165
+ {used && (
166
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
167
+ in use
168
+ </span>
169
+ )}
170
+ </div>
171
+ {bars.length > 0 && (
172
+ <div className="flex items-end gap-[2px] h-[14px] mt-0.5">
173
+ {bars.map((v, i) => (
174
+ <div
175
+ key={i}
176
+ className="flex-1 rounded-[1px]"
177
+ style={{
178
+ height: `${Math.max(10, v * 100)}%`,
179
+ background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
180
+ transition: "height 80ms ease-out",
181
+ }}
182
+ />
183
+ ))}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </div>
188
+
189
+ {contextMenu && (
190
+ <ContextMenu
191
+ x={contextMenu.x}
192
+ y={contextMenu.y}
193
+ asset={asset}
194
+ onClose={() => setContextMenu(null)}
195
+ onCopy={onCopy}
196
+ onDelete={onDelete}
197
+ onRename={onRename}
198
+ />
199
+ )}
200
+ </>
201
+ );
202
+ }
@@ -55,6 +55,7 @@ export interface DomEditActionsValue extends Pick<
55
55
  | "handleGsapRemoveAllKeyframes"
56
56
  | "handleResetSelectedElementKeyframes"
57
57
  | "commitAnimatedProperty"
58
+ | "commitAnimatedProperties"
58
59
  | "handleSetArcPath"
59
60
  | "handleUpdateArcSegment"
60
61
  | "handleUnroll"
@@ -164,6 +165,7 @@ export function DomEditProvider({
164
165
  handleGsapRemoveAllKeyframes,
165
166
  handleResetSelectedElementKeyframes,
166
167
  commitAnimatedProperty,
168
+ commitAnimatedProperties,
167
169
  handleSetArcPath,
168
170
  handleUpdateArcSegment,
169
171
  handleUnroll,
@@ -238,6 +240,7 @@ export function DomEditProvider({
238
240
  handleGsapRemoveAllKeyframes,
239
241
  handleResetSelectedElementKeyframes,
240
242
  commitAnimatedProperty,
243
+ commitAnimatedProperties,
241
244
  handleSetArcPath,
242
245
  handleUpdateArcSegment,
243
246
  handleUnroll,
@@ -298,6 +301,7 @@ export function DomEditProvider({
298
301
  handleGsapRemoveAllKeyframes,
299
302
  handleResetSelectedElementKeyframes,
300
303
  commitAnimatedProperty,
304
+ commitAnimatedProperties,
301
305
  handleSetArcPath,
302
306
  handleUpdateArcSegment,
303
307
  handleUnroll,
@@ -332,7 +332,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
332
332
  expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
333
333
  });
334
334
 
335
- it("does NOT attach instantPatch when ADDING a new set (structural new tween)", async () => {
335
+ it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
336
336
  const { commits, callbacks } = optionRecordingCallbacks();
337
337
 
338
338
  await commitStaticGsapPosition(
@@ -340,13 +340,15 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
340
340
  { x: -50, y: 30 },
341
341
  { x: 0, y: 0 },
342
342
  "#puck-a",
343
- null, // no existing set → `add` a new tween
343
+ null, // no existing set → `add` a new base gsap.set
344
344
  callbacks,
345
345
  );
346
346
 
347
347
  expect(commits).toHaveLength(1);
348
348
  expect(commits[0].mutation.type).toBe("add");
349
- expect(commits[0].options.instantPatch).toBeUndefined();
349
+ expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
350
+ const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
351
+ expect(patch?.change.kind).toBe("global-set");
350
352
  });
351
353
  });
352
354
 
@@ -372,14 +374,16 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
372
374
  expect(patch.change.props[m.property]).toBe(m.value);
373
375
  });
374
376
 
375
- it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {
377
+ it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
376
378
  const { commits, callbacks } = optionRecordingCallbacks();
377
379
 
378
380
  await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
379
381
 
380
382
  expect(commits).toHaveLength(1);
381
383
  expect(commits[0].mutation.type).toBe("add");
382
- expect(commits[0].options.instantPatch).toBeUndefined();
384
+ expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
385
+ const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
386
+ expect(patch?.change.kind).toBe("global-set");
383
387
  });
384
388
  });
385
389
 
@@ -120,18 +120,22 @@ interface UpdatePropertyMutation {
120
120
  function setPatchFromUpdateProperties(
121
121
  selector: string,
122
122
  mutations: UpdatePropertyMutation[],
123
+ global = false,
123
124
  ): { selector: string; change: RuntimeTweenChange } {
124
125
  const props: SetPatchProps = {};
125
126
  for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
126
- return { selector, change: { kind: "set", props } };
127
+ // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
128
+ // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
129
+ return { selector, change: { kind: global ? "global-set" : "set", props } };
127
130
  }
128
131
 
129
132
  /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
130
133
  function setPatchFromUpdateProperty(
131
134
  selector: string,
132
135
  mutation: UpdatePropertyMutation,
136
+ global = false,
133
137
  ): { selector: string; change: RuntimeTweenChange } {
134
- return setPatchFromUpdateProperties(selector, [mutation]);
138
+ return setPatchFromUpdateProperties(selector, [mutation], global);
135
139
  }
136
140
 
137
141
  /**
@@ -194,22 +198,25 @@ export async function commitStaticGsapPosition(
194
198
  // preview still reflects what DID persist. The x commit carries skipReload
195
199
  // (no reload), so its instantPatch gives instant feedback without a reload;
196
200
  // the y commit triggers the soft reload (skipped when the patch applies).
201
+ const global = !!existingSet.global;
197
202
  await callbacks.commitMutation(selection, xMutation, {
198
203
  label: "Move layer",
199
204
  skipReload: true,
200
205
  coalesceKey,
201
- instantPatch: setPatchFromUpdateProperty(selector, xMutation),
206
+ instantPatch: setPatchFromUpdateProperty(selector, xMutation, global),
202
207
  });
203
208
  await callbacks.commitMutation(selection, yMutation, {
204
209
  label: "Move layer",
205
210
  softReload: true,
206
211
  coalesceKey,
207
212
  // Final commit of the coalesced x/y pair: carry both channels so the
208
- // runtime `tl.set` lands the complete {x,y} pose in place.
209
- instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
213
+ // runtime set lands the complete {x,y} pose in place.
214
+ instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation], global),
210
215
  });
211
216
  return;
212
217
  }
218
+ // New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
219
+ // an instant patch so the first nudge shows immediately (no soft-reload flash).
213
220
  await callbacks.commitMutation(
214
221
  selection,
215
222
  {
@@ -218,8 +225,13 @@ export async function commitStaticGsapPosition(
218
225
  method: "set",
219
226
  position: 0,
220
227
  properties: { x: newX, y: newY },
228
+ global: true,
229
+ },
230
+ {
231
+ label: "Move layer",
232
+ softReload: true,
233
+ instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
221
234
  },
222
- { label: "Move layer", softReload: true },
223
235
  );
224
236
  }
225
237
 
@@ -264,11 +276,13 @@ export async function commitStaticGsapRotation(
264
276
  await callbacks.commitMutation(selection, rotationMutation, {
265
277
  label: "Rotate layer",
266
278
  softReload: true,
267
- // Value-only rotation set: patch the runtime `tl.set` rotation in place.
268
- instantPatch: setPatchFromUpdateProperty(selector, rotationMutation),
279
+ // Value-only rotation set patch the runtime in place (off-timeline gsap.set
280
+ // applies to the element directly; on-timeline tl.set patches its tween).
281
+ instantPatch: setPatchFromUpdateProperty(selector, rotationMutation, !!existingSet.global),
269
282
  });
270
283
  return;
271
284
  }
285
+ // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
272
286
  await callbacks.commitMutation(
273
287
  selection,
274
288
  {
@@ -277,8 +291,13 @@ export async function commitStaticGsapRotation(
277
291
  method: "set",
278
292
  position: 0,
279
293
  properties: { rotation: newRotation },
294
+ global: true,
295
+ },
296
+ {
297
+ label: "Rotate layer",
298
+ softReload: true,
299
+ instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } },
280
300
  },
281
- { label: "Rotate layer", softReload: true },
282
301
  );
283
302
  }
284
303
 
@@ -12,12 +12,23 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
13
  import { roundTo3 } from "../utils/rounding";
14
14
 
15
+ /**
16
+ * A GSAP tween's `vars` object — intentionally open: it mixes channel values
17
+ * (numbers), easing (strings), flags (booleans), nested keyframes (objects) and
18
+ * callbacks. Named so call sites read as "GSAP config", not an untyped escape hatch.
19
+ */
20
+ export type GsapVars = Record<string, unknown>;
21
+
15
22
  export interface RuntimeTween {
16
23
  targets?: () => Element[];
17
- vars?: Record<string, unknown>;
24
+ vars?: GsapVars;
18
25
  duration?: () => number;
19
26
  startTime?: () => number;
20
27
  invalidate?: () => RuntimeTween;
28
+ /** Remove this tween from its parent timeline (GSAP `kill()`). */
29
+ kill?: () => void;
30
+ /** The timeline this tween lives in — used to re-insert a rebuilt tween. */
31
+ parent?: RuntimeTimeline;
21
32
  }
22
33
 
23
34
  export interface RuntimeTimeline {
@@ -25,6 +36,8 @@ export interface RuntimeTimeline {
25
36
  duration?: () => number;
26
37
  time?: () => number;
27
38
  invalidate?: () => RuntimeTimeline;
39
+ /** Add a tween at an absolute position — used to rebuild a keyframe tween in place. */
40
+ to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
28
41
  }
29
42
 
30
43
  type Pct = { percentage: number; properties: Record<string, number | string> };
@@ -184,6 +197,26 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
184
197
  return false;
185
198
  }
186
199
 
200
+ /**
201
+ * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
202
+ * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
203
+ * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`).
204
+ */
205
+ function keyframeVarsCarryChannel(
206
+ vars: Record<string, unknown> | undefined,
207
+ channels: string[],
208
+ ): boolean {
209
+ const kf = vars?.keyframes;
210
+ if (!kf || typeof kf !== "object") return false;
211
+ const steps = Array.isArray(kf) ? kf : Object.values(kf);
212
+ return steps.some(
213
+ (step) =>
214
+ step != null &&
215
+ typeof step === "object" &&
216
+ channels.some((ch) => Object.prototype.hasOwnProperty.call(step, ch)),
217
+ );
218
+ }
219
+
187
220
  /**
188
221
  * Resolve the live tween targeting `selector` using the SAME all-timelines scan
189
222
  * `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
@@ -197,6 +230,7 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
197
230
  * on a rotation-only set). With no channel-matching set, it falls back to the
198
231
  * first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
199
232
  */
233
+ // fallow-ignore-next-line complexity
200
234
  export function resolveRuntimeTween(
201
235
  iframe: HTMLIFrameElement | null,
202
236
  selector: string,
@@ -219,7 +253,12 @@ export function resolveRuntimeTween(
219
253
  ? [compositionId]
220
254
  : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
221
255
 
222
- const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null;
256
+ // Channels disambiguate co-located tweens for BOTH kinds: a `set` carries them as
257
+ // own vars props, a keyframe tween carries them inside its keyframe steps. An
258
+ // element can have a rotation keyframe tween AND a position keyframe tween; a
259
+ // rotation edit must land on the former. The reader passes no channels, so its
260
+ // playhead-containment path below is unchanged.
261
+ const wantChannels = channels && channels.length > 0 ? channels : null;
223
262
 
224
263
  let first: ResolvedRuntimeTween | null = null;
225
264
  let channelMatch: ResolvedRuntimeTween | null = null;
@@ -233,11 +272,15 @@ export function resolveRuntimeTween(
233
272
  const isSet = !(dur > 0);
234
273
  if (kind === "set" ? !isSet : isSet) continue;
235
274
  if (wantChannels) {
236
- if (varsCarryChannel(tween.vars, wantChannels)) {
275
+ const carries =
276
+ kind === "set"
277
+ ? varsCarryChannel(tween.vars, wantChannels)
278
+ : keyframeVarsCarryChannel(tween.vars, wantChannels);
279
+ if (carries) {
237
280
  if (channelMatch === null) channelMatch = { tween, timeline };
238
281
  } else if (first === null) {
239
- // A set carrying only disjoint channels: remember as last-resort
240
- // fallback, but never prefer it over a channel-matching set.
282
+ // A tween carrying only disjoint channels: remember as last-resort
283
+ // fallback, but never prefer it over a channel-matching one.
241
284
  first = { tween, timeline };
242
285
  }
243
286
  continue;
@@ -267,6 +310,7 @@ function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
267
310
  * whenever the playhead sits in that tween's range but outside the position
268
311
  * tween's). Omitted → any keyframed tween qualifies (back-compat).
269
312
  */
313
+ // fallow-ignore-next-line complexity
270
314
  export function readRuntimeKeyframes(
271
315
  iframe: HTMLIFrameElement | null,
272
316
  selector: string,
@@ -334,6 +378,7 @@ export function readRuntimeKeyframes(
334
378
  * only a hold may remain, and resurrecting the deleted tween from the stale parse
335
379
  * must be avoided.
336
380
  */
381
+ // fallow-ignore-next-line complexity
337
382
  export function hasNonHoldTweenForElement(
338
383
  iframe: HTMLIFrameElement | null,
339
384
  selector: string,