@hyperframes/studio 0.8.149 → 0.8.151

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.
@@ -1 +1 @@
1
- import{h as P}from"./index-ByJU5J6n.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var h=e[o+x]-t,_=r.some(function(g){if(g.interval===h)return g.count+=1});_||r.push({interval:h,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
1
+ import{h as P}from"./index-BGks9KLI.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var h=e[o+x]-t,_=r.some(function(g){if(g.interval===h)return g.count+=1});_||r.push({interval:h,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-ByJU5J6n.js";/*!
1
+ import{n as Qi}from"./index-BGks9KLI.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
package/dist/index.d.ts CHANGED
@@ -354,9 +354,10 @@ interface TimelineElement {
354
354
  /** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
355
355
  fadeIn?: number;
356
356
  fadeOut?: number;
357
- /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
357
+ /** Verbatim `data-fx-chain` / `data-automation` (`masterChain`: the root's); see automationLaneData. */
358
358
  fxChain?: string;
359
359
  automation?: string;
360
+ masterChain?: string;
360
361
  /** Path from data-composition-src — identifies sub-composition elements */
361
362
  compositionSrc?: string;
362
363
  /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
package/dist/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-ByJU5J6n.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-BGks9KLI.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-DkQKsFxx.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -5026,17 +5026,7 @@ function matchesSelector(tweenSelector, querySelector) {
5026
5026
  }
5027
5027
 
5028
5028
  // src/hooks/gsapShared.ts
5029
- var PROPERTY_DEFAULTS = {
5030
- opacity: 1,
5031
- x: 0,
5032
- y: 0,
5033
- scale: 1,
5034
- scaleX: 1,
5035
- scaleY: 1,
5036
- rotation: 0,
5037
- width: 100,
5038
- height: 100
5039
- };
5029
+ import { KEYFRAME_PROPERTY_DEFAULTS } from "@hyperframes/core/keyframe-defaults";
5040
5030
  function keyframeEases(anim) {
5041
5031
  if (!anim.keyframes) return { easeEach: anim.ease };
5042
5032
  return { ease: runEaseOf(anim), easeEach: anim.keyframes.easeEach };
@@ -5327,14 +5317,14 @@ function synthesizeFlatTweenKeyframes(anim) {
5327
5317
  if (anim.method === "from") {
5328
5318
  for (const [k, v] of Object.entries(toProps)) {
5329
5319
  rawStart[k] = v;
5330
- rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
5320
+ rawEnd[k] = KEYFRAME_PROPERTY_DEFAULTS[k] ?? 0;
5331
5321
  }
5332
5322
  } else if (anim.method === "fromTo" && fromProps) {
5333
5323
  Object.assign(rawStart, fromProps);
5334
5324
  Object.assign(rawEnd, toProps);
5335
5325
  } else {
5336
5326
  for (const [k, v] of Object.entries(toProps)) {
5337
- rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
5327
+ rawStart[k] = KEYFRAME_PROPERTY_DEFAULTS[k] ?? 0;
5338
5328
  rawEnd[k] = v;
5339
5329
  }
5340
5330
  }
@@ -23030,7 +23020,7 @@ function propertyStops(animation2, property, start) {
23030
23020
  }));
23031
23021
  const first = stops[0];
23032
23022
  if (!first || first.percentage <= 0 || typeof first.value !== "number") return stops;
23033
- return [{ percentage: 0, value: start ?? PROPERTY_DEFAULTS[property] ?? 0 }, ...stops];
23023
+ return [{ percentage: 0, value: start ?? KEYFRAME_PROPERTY_DEFAULTS[property] ?? 0 }, ...stops];
23034
23024
  }
23035
23025
  function interpolableEnds(before, after) {
23036
23026
  if (typeof before.value !== "number" || typeof after.value !== "number") return null;
@@ -29115,6 +29105,7 @@ import { useCallback as useCallback38, useRef as useRef46 } from "react";
29115
29105
  // src/player/lib/timelineDOM.ts
29116
29106
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
29117
29107
  import { readClipTiming as readClipTiming2 } from "@hyperframes/core/composition-contract";
29108
+ import { readMasterFxChain } from "@hyperframes/core/audio-groups";
29118
29109
  import { linkScopeOf } from "@hyperframes/core/media-link";
29119
29110
  import { createRuntimeStartTimeResolver as createRuntimeStartTimeResolver2 } from "@hyperframes/core/runtime/start-resolver";
29120
29111
 
@@ -29693,6 +29684,8 @@ function createTimelineElementFromManifestClip(params) {
29693
29684
  if (info.fxChain) entry2.audioGroupFxChain = info.fxChain;
29694
29685
  if (info.automation) entry2.audioGroupAutomation = info.automation;
29695
29686
  }
29687
+ const masterChain = readMasterFxChain(doc ?? hostEl.ownerDocument);
29688
+ if (masterChain) entry2.masterChain = masterChain;
29696
29689
  const fxChain = hostEl.getAttribute("data-fx-chain");
29697
29690
  if (fxChain) entry2.fxChain = fxChain;
29698
29691
  const automation = hostEl.getAttribute("data-automation");
@@ -29815,6 +29808,8 @@ function parseTimelineFromDOM(doc, rootDuration, timelines) {
29815
29808
  const resolvedSrc = mediaEl.src || void 0;
29816
29809
  if (resolvedSrc) entry2.src = resolvedSrc;
29817
29810
  }
29811
+ const domMasterChain = readMasterFxChain(doc);
29812
+ if (domMasterChain) entry2.masterChain = domMasterChain;
29818
29813
  const domFxChain = el.getAttribute("data-fx-chain");
29819
29814
  if (domFxChain) entry2.fxChain = domFxChain;
29820
29815
  const domAutomation = el.getAttribute("data-automation");
@@ -47639,7 +47634,7 @@ async function commitWholePropertyOffset(selection, anim, newValues, currentPct,
47639
47634
  const ts = resolveTweenStart(effectiveAnim);
47640
47635
  const td = resolveTweenDuration(effectiveAnim);
47641
47636
  const keys = Object.keys(newValues);
47642
- const at = (props, key2) => typeof props[key2] === "number" ? props[key2] : PROPERTY_DEFAULTS[key2] ?? 0;
47637
+ const at = (props, key2) => typeof props[key2] === "number" ? props[key2] : KEYFRAME_PROPERTY_DEFAULTS[key2] ?? 0;
47643
47638
  const kfs = effectiveAnim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes;
47644
47639
  if (!kfs || kfs.length === 0) {
47645
47640
  await callbacks.commitMutation(
@@ -74831,7 +74826,9 @@ function claimHeld(reply, label) {
74831
74826
  if (reply.ok)
74832
74827
  return reply.body?.claimed?.id ?? null;
74833
74828
  if (reply.status !== 404)
74834
- console.error(`"${label}" was not recorded as your edit: ${reply.error}`);
74829
+ console.error(
74830
+ `[history] ${reply.status}: "${label}" was not recorded as your edit: ${reply.error}`
74831
+ );
74835
74832
  return null;
74836
74833
  }
74837
74834
  async function overwroteVersions(files) {
@@ -82590,7 +82587,7 @@ async function commitKeyframeAtTimeImpl(selection, absoluteTime, animations, pro
82590
82587
  }
82591
82588
  const backfillDefaults = {};
82592
82589
  for (const key2 of Object.keys(properties)) {
82593
- backfillDefaults[key2] = PROPERTY_DEFAULTS[key2] ?? 0;
82590
+ backfillDefaults[key2] = KEYFRAME_PROPERTY_DEFAULTS[key2] ?? 0;
82594
82591
  }
82595
82592
  await commitMutation(
82596
82593
  selection,
@@ -83011,7 +83008,7 @@ function useGsapPropertyDebounce(commitMutationSafely, sdk) {
83011
83008
  const addGsapProperty = useCallback137(
83012
83009
  // fallow-ignore-next-line complexity
83013
83010
  async (selection, animationId, property) => {
83014
- let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
83011
+ let defaultValue = KEYFRAME_PROPERTY_DEFAULTS[property] ?? 0;
83015
83012
  const el = selection.element;
83016
83013
  if (property === "width" || property === "height") {
83017
83014
  const rect = el.getBoundingClientRect();
@@ -83133,7 +83130,7 @@ function useGsapPropertyDebounce(commitMutationSafely, sdk) {
83133
83130
  );
83134
83131
  const addGsapFromProperty = useCallback137(
83135
83132
  async (selection, animationId, property) => {
83136
- const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
83133
+ const defaultValue = KEYFRAME_PROPERTY_DEFAULTS[property] ?? 0;
83137
83134
  const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
83138
83135
  if (sdkSession && sdkDeps) {
83139
83136
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.149",
3
+ "version": "0.8.151",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.149",
64
- "@hyperframes/player": "0.8.149",
65
- "@hyperframes/parsers": "0.8.149",
66
- "@hyperframes/shader-transitions": "0.8.149",
67
- "@hyperframes/sdk": "0.8.149",
68
- "@hyperframes/studio-server": "0.8.149"
63
+ "@hyperframes/player": "0.8.151",
64
+ "@hyperframes/sdk": "0.8.151",
65
+ "@hyperframes/core": "0.8.151",
66
+ "@hyperframes/shader-transitions": "0.8.151",
67
+ "@hyperframes/studio-server": "0.8.151",
68
+ "@hyperframes/parsers": "0.8.151"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.149"
85
+ "@hyperframes/producer": "0.8.151"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -25,17 +25,7 @@ export interface IframeGsap {
25
25
 
26
26
  // ── Constants ─────────────────────────────────────────────────────────────────
27
27
 
28
- export const PROPERTY_DEFAULTS: Record<string, number> = {
29
- opacity: 1,
30
- x: 0,
31
- y: 0,
32
- scale: 1,
33
- scaleX: 1,
34
- scaleY: 1,
35
- rotation: 0,
36
- width: 100,
37
- height: 100,
38
- };
28
+ export { KEYFRAME_PROPERTY_DEFAULTS as PROPERTY_DEFAULTS } from "@hyperframes/core/keyframe-defaults";
39
29
 
40
30
  /** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
41
31
  * since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
@@ -561,7 +561,9 @@ it("a claim the server refused is logged, and the write stays undoable as a chan
561
561
  files: { "index.html": { before: "A", after: "B" } },
562
562
  }),
563
563
  );
564
- expect(logged).toHaveBeenCalledWith('"Moved Title" was not recorded as your edit: disk full');
564
+ expect(logged).toHaveBeenCalledWith(
565
+ '[history] 500: "Moved Title" was not recorded as your edit: disk full',
566
+ );
565
567
  restoreFetch();
566
568
  expect(await act(() => hook().undo({ readFile }))).toMatchObject({
567
569
  ok: true,
@@ -570,6 +572,46 @@ it("a claim the server refused is logged, and the write stays undoable as a chan
570
572
  expect(file()).toBe("A");
571
573
  });
572
574
 
575
+ it.each([
576
+ [
577
+ "the server refused",
578
+ 409,
579
+ () => answer("/history/claim", 409, { error: "newer edit" }),
580
+ "newer edit",
581
+ ],
582
+ [
583
+ "could not reach the server",
584
+ 0,
585
+ () => {
586
+ const real = globalThis.fetch;
587
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
588
+ url.endsWith("/history/claim")
589
+ ? Promise.reject(new TypeError("fetch failed"))
590
+ : real(url, init),
591
+ );
592
+ },
593
+ "Studio could not reach its server.",
594
+ ],
595
+ ] as const)(
596
+ "a claim that %s logs under [history] with its status",
597
+ async (_, status, stub, reason) => {
598
+ const { hook, save } = await studio();
599
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
600
+ cleanup.push(() => logged.mockRestore());
601
+ stub();
602
+ save("B");
603
+ await act(() =>
604
+ hook().recordEdit({
605
+ label: "Moved Title",
606
+ files: { "index.html": { before: "A", after: "B" } },
607
+ }),
608
+ );
609
+ expect(logged).toHaveBeenCalledWith(
610
+ `[history] ${status}: "Moved Title" was not recorded as your edit: ${reason}`,
611
+ );
612
+ },
613
+ );
614
+
573
615
  it("a step whose reply cannot be read says so, instead of throwing", async () => {
574
616
  const { hook, readFile } = await studio();
575
617
  answer("/history/step", 200, "<html>");
@@ -154,7 +154,9 @@ function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string): stri
154
154
  if (reply.ok)
155
155
  return (reply.body as { claimed: { id: string } | null } | null)?.claimed?.id ?? null;
156
156
  if (reply.status !== 404)
157
- console.error(`"${label}" was not recorded as your edit: ${reply.error}`);
157
+ console.error(
158
+ `[history] ${reply.status}: "${label}" was not recorded as your edit: ${reply.error}`,
159
+ );
158
160
  return null;
159
161
  }
160
162
 
@@ -632,6 +632,61 @@ describe("audio FX attributes on parsed elements", () => {
632
632
  });
633
633
  });
634
634
 
635
+ describe("the master chain on parsed elements", () => {
636
+ const CHAIN = '{"version":1,"nodes":[{"type":"truepeak","id":"m1","params":{}}]}';
637
+ const parseVideo = (rootAttrs: string, inner = ""): TimelineElement | undefined => {
638
+ const doc = new DOMParser().parseFromString(
639
+ `<div data-composition-id="main" data-start="0" data-duration="10" ${rootAttrs}>
640
+ <video id="talk" data-start="0" data-duration="10" data-has-audio="true"></video>${inner}
641
+ </div>`,
642
+ "text/html",
643
+ );
644
+ return parseTimelineFromDOM(doc, 10).find((e) => e.domId === "talk");
645
+ };
646
+
647
+ it("copies the root's data-fx-chain onto an element that has none of its own", () => {
648
+ const talk = parseVideo(`data-fx-chain='${CHAIN}'`);
649
+ expect(talk?.masterChain).toBe(CHAIN);
650
+ expect(talk?.fxChain).toBeUndefined();
651
+ });
652
+
653
+ it("leaves it unset when the root carries none", () => {
654
+ expect(parseVideo("")?.masterChain).toBeUndefined();
655
+ });
656
+
657
+ it("ignores a sub-composition root's chain", () => {
658
+ const inner = `<div data-composition-id="inner" data-fx-chain='${CHAIN}'></div>`;
659
+ expect(parseVideo("", inner)?.masterChain).toBeUndefined();
660
+ });
661
+
662
+ it("carries it on the manifest path too", () => {
663
+ const doc = makeDoc(`
664
+ <div data-composition-id="main" data-fx-chain='${CHAIN}'>
665
+ <video id="talk" data-has-audio="true"></video>
666
+ </div>
667
+ `);
668
+ const element = createTimelineElementFromManifestClip({
669
+ clip: {
670
+ id: "talk",
671
+ label: "Talk",
672
+ kind: "video",
673
+ tagName: "video",
674
+ start: 0,
675
+ duration: 10,
676
+ track: 0,
677
+ compositionId: null,
678
+ parentCompositionId: "main",
679
+ compositionSrc: null,
680
+ assetUrl: null,
681
+ },
682
+ fallbackIndex: 0,
683
+ doc,
684
+ hostEl: doc.getElementById("talk"),
685
+ });
686
+ expect(element.masterChain).toBe(CHAIN);
687
+ });
688
+ });
689
+
635
690
  // A composition clip trimmed by 30 px collapsed to its first nested video's 1.77 s: that length capped the trim.
636
691
  describe("a composition clip's source length", () => {
637
692
  const scene = `
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
+ import { readMasterFxChain } from "@hyperframes/core/audio-groups";
15
16
  import { linkScopeOf } from "@hyperframes/core/media-link";
16
17
  import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
17
18
  import { groupInfoFor } from "./timelineGroupInfo";
@@ -167,6 +168,8 @@ export function createTimelineElementFromManifestClip(params: {
167
168
  if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
168
169
  if (info.automation) entry.audioGroupAutomation = info.automation;
169
170
  }
171
+ const masterChain = readMasterFxChain(doc ?? hostEl.ownerDocument);
172
+ if (masterChain) entry.masterChain = masterChain;
170
173
  const fxChain = hostEl.getAttribute("data-fx-chain");
171
174
  if (fxChain) entry.fxChain = fxChain;
172
175
  const automation = hostEl.getAttribute("data-automation");
@@ -330,6 +333,8 @@ export function parseTimelineFromDOM(
330
333
  // Read from the element, like the manifest path does: without these an audio
331
334
  // clip parsed straight from the DOM reserved no automation height and drew no
332
335
  // lanes, while the property panel still showed its chain.
336
+ const domMasterChain = readMasterFxChain(doc);
337
+ if (domMasterChain) entry.masterChain = domMasterChain;
333
338
  const domFxChain = el.getAttribute("data-fx-chain");
334
339
  if (domFxChain) entry.fxChain = domFxChain;
335
340
  const domAutomation = el.getAttribute("data-automation");
@@ -62,9 +62,10 @@ export interface TimelineElement {
62
62
  /** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
63
63
  fadeIn?: number;
64
64
  fadeOut?: number;
65
- /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
65
+ /** Verbatim `data-fx-chain` / `data-automation` (`masterChain`: the root's); see automationLaneData. */
66
66
  fxChain?: string;
67
67
  automation?: string;
68
+ masterChain?: string;
68
69
  /** Path from data-composition-src — identifies sub-composition elements */
69
70
  compositionSrc?: string;
70
71
  /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
@@ -69,6 +69,15 @@ describe("hasProjectAudio", () => {
69
69
  expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, ...fields })])).toBe(true);
70
70
  });
71
71
 
72
+ it("counts an audible video when the composition root carries a master chain", () => {
73
+ const masterChain = '{"version":1,"nodes":[]}';
74
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, masterChain })])).toBe(true);
75
+ expect(
76
+ hasProjectAudio([clip({ tag: "video", muted: true, hasAudio: true, masterChain })]),
77
+ ).toBe(false);
78
+ expect(hasProjectAudio([clip({ tag: "video", masterChain })])).toBe(false);
79
+ });
80
+
72
81
  it("ignores a muted video even when it carries processing", () => {
73
82
  expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, muted: true, volume: 2 })])).toBe(
74
83
  false,