mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(studio): keep preview state synchronized (#3450)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state * fix(studio): make audio-group edits transactional * fix(studio): keep preview state synchronized
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Which elements a delete acts on.
|
||||
*
|
||||
* Its own module so `useDomEditSession.ts` stays under the studio's 600-line
|
||||
* cap; it reads only its arguments.
|
||||
*/
|
||||
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
/** One entry in the studio's edit history, as `useDomEditSession`'s caller
|
||||
* supplies it. */
|
||||
export interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which elements a delete acts on. `expandGroup` widens the primary to the
|
||||
* whole marquee group, which is what the Delete key means.
|
||||
*
|
||||
* The caller chooses rather than the delete deciding for everyone: Cut copies
|
||||
* the primary alone, so expanding for it put one element on the clipboard and
|
||||
* removed every other member of the group with it.
|
||||
*/
|
||||
export function membersForDelete(
|
||||
selection: DomEditSelection,
|
||||
group: DomEditSelection[],
|
||||
options?: { expandGroup?: boolean },
|
||||
): DomEditSelection[] {
|
||||
return options?.expandGroup && group.length > 0 ? group : [selection];
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
||||
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
||||
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
||||
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
||||
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -63,6 +65,17 @@ function setOrRemovePreviewAttribute(
|
||||
} else {
|
||||
el.setAttribute(fullAttr, value);
|
||||
}
|
||||
// Every DOM-edit attribute write funnels through here, which is the only
|
||||
// place that can catch a group edit made from the rack rather than from the
|
||||
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
|
||||
// editor, and that path never went near the timeline's own writers.
|
||||
//
|
||||
// The group element itself OR a member's membership attribute: writing
|
||||
// `data-audio-group` onto an `<audio>` moves it between groups, which changes
|
||||
// the answer just as much as editing the bus does.
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG || fullAttr === HF_AUDIO_GROUP_ATTR) {
|
||||
invalidateGroupInfoCache(el.ownerDocument);
|
||||
}
|
||||
}
|
||||
|
||||
function findPreviewAttributeElement(
|
||||
|
||||
@@ -58,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
|
||||
fn: undefined,
|
||||
};
|
||||
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
||||
const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] };
|
||||
const groupSelectionSpy = vi.fn();
|
||||
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
||||
|
||||
function createSessionParams(
|
||||
@@ -131,7 +133,7 @@ vi.mock("./useDomSelection", () => ({
|
||||
domEditHoverSelection: null,
|
||||
activeGroupElement: null,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef: { current: [] },
|
||||
domEditGroupSelectionsRef,
|
||||
setActiveGroupElement: vi.fn(),
|
||||
applyDomSelection: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
@@ -190,7 +192,7 @@ vi.mock("./useGsapScriptCommits", () => ({
|
||||
}));
|
||||
vi.mock("./useGroupCommits", () => ({
|
||||
useGroupCommits: () => ({
|
||||
groupSelection: vi.fn(),
|
||||
groupSelection: (...args: unknown[]) => groupSelectionSpy(...args),
|
||||
ungroupSelection: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
@@ -407,3 +409,56 @@ describe("bulk segment ease commits", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Grouping refuses audio ───────────────────────────────────────────────────
|
||||
//
|
||||
// A layout group is a positioned wrapper: it takes the members' bounding box,
|
||||
// rebases each child's left/top against it and adopts the topmost z-index. An
|
||||
// <audio> clip has no box — offsetWidth/Height are 0 — so this produced a 0x0
|
||||
// div with inline left/top on elements that are never laid out. Enforced here
|
||||
// rather than only by hiding the button, because the G shortcut routes through
|
||||
// the same handler and no hidden button can gate a keystroke.
|
||||
|
||||
describe("handleGroupSelection with audio in the selection", () => {
|
||||
const sel = (tag: string): DomEditSelection =>
|
||||
({
|
||||
id: tag,
|
||||
element: document.createElement(tag),
|
||||
sourceFile: "index.html",
|
||||
}) as unknown as DomEditSelection;
|
||||
|
||||
async function group(members: DomEditSelection[]) {
|
||||
const { useDomEditSession } = await import("./useDomEditSession");
|
||||
groupSelectionSpy.mockClear();
|
||||
domEditGroupSelectionsRef.current = members;
|
||||
const showToast = vi.fn();
|
||||
const captured: { fn?: () => void } = {};
|
||||
function Probe() {
|
||||
captured.fn = useDomEditSession(createSessionParams({ showToast })).handleGroupSelection;
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(<Probe />));
|
||||
act(() => captured.fn?.());
|
||||
act(() => root.unmount());
|
||||
domEditGroupSelectionsRef.current = [];
|
||||
return { showToast };
|
||||
}
|
||||
|
||||
it("refuses a selection of audio clips, and says where grouping audio lives", async () => {
|
||||
const { showToast } = await group([sel("audio"), sel("audio")]);
|
||||
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
||||
expect(String(showToast.mock.calls[0]?.[0])).toContain("bus");
|
||||
});
|
||||
|
||||
it("refuses a mixed selection, since the wrapper would take the audio in too", async () => {
|
||||
const { showToast } = await group([sel("div"), sel("audio")]);
|
||||
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
||||
expect(String(showToast.mock.calls[0]?.[0])).toContain("layout");
|
||||
});
|
||||
|
||||
it("still groups a selection of layout elements", async () => {
|
||||
await group([sel("div"), sel("span")]);
|
||||
expect(groupSelectionSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { isAudioDomElement } from "../utils/timelineInspector";
|
||||
import type { SelectElementOptions, TimelineElement } from "../player";
|
||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
||||
@@ -21,13 +21,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
||||
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
||||
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
import { membersForDelete } from "./domEditDeleteMembers";
|
||||
import type { RecordEditInput } from "./domEditDeleteMembers";
|
||||
// Re-exported: the delete rule lives in its own module now, and callers (and its
|
||||
// own test) have always imported it from here.
|
||||
export { membersForDelete };
|
||||
|
||||
export interface UseDomEditSessionParams {
|
||||
projectId: string | null;
|
||||
@@ -73,22 +71,6 @@ export interface UseDomEditSessionParams {
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which elements a delete acts on. `expandGroup` widens the primary to the
|
||||
* whole marquee group, which is what the Delete key means.
|
||||
*
|
||||
* The caller chooses rather than the delete deciding for everyone: Cut copies
|
||||
* the primary alone, so expanding for it put one element on the clipboard and
|
||||
* removed every other member of the group with it.
|
||||
*/
|
||||
export function membersForDelete(
|
||||
selection: DomEditSelection,
|
||||
group: DomEditSelection[],
|
||||
options?: { expandGroup?: boolean },
|
||||
): DomEditSelection[] {
|
||||
return options?.expandGroup && group.length > 0 ? group : [selection];
|
||||
}
|
||||
|
||||
export function useDomEditSession({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -374,6 +356,23 @@ export function useDomEditSession({
|
||||
showToast("Select at least 2 elements to group", "info");
|
||||
return;
|
||||
}
|
||||
// A layout group is a positioned wrapper: it takes the members' bounding
|
||||
// box, rebases each child's left/top against it, and adopts the topmost
|
||||
// z-index. An <audio> clip has no box — offsetWidth/Height are 0 — so
|
||||
// grouping audio produced a 0x0 div with inline left/top written onto
|
||||
// elements that have never been laid out, and the timeline gained a
|
||||
// wrapper standing for nothing audible. The audio answer to "these clips
|
||||
// belong together" is an <hf-audio-group> bus, which the timeline's own FX
|
||||
// pointer creates, so the refusal names it rather than just declining.
|
||||
if (members.some((m) => isAudioDomElement(m.element))) {
|
||||
showToast(
|
||||
members.every((m) => isAudioDomElement(m.element))
|
||||
? "Audio clips group into a bus — use FX on the track header"
|
||||
: "Can't group audio clips with layout elements",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("group", { action: "create", count: members.length });
|
||||
void groupSelection(members);
|
||||
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TimelineElement } from "../player/store/timelineElement";
|
||||
import { getEffectiveTimelineDuration } from "../player/components/timelineViewModel";
|
||||
|
||||
/**
|
||||
* The stored `duration` lags a moment behind an edit that pushes an element
|
||||
@@ -10,11 +11,12 @@ export function useEffectiveTimelineDuration(
|
||||
timelineDuration: number,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
): number {
|
||||
return useMemo(() => {
|
||||
const maxEnd =
|
||||
timelineElements.length > 0
|
||||
? Math.max(...timelineElements.map((el) => el.start + el.duration))
|
||||
: 0;
|
||||
return Math.max(timelineDuration, maxEnd);
|
||||
}, [timelineDuration, timelineElements]);
|
||||
// Delegates to `getEffectiveTimelineDuration` rather than restating the
|
||||
// arithmetic: that one guards a non-finite stored duration and a non-finite
|
||||
// result (an element with NaN timing), which this copy did not — it would
|
||||
// return NaN and every downstream width became NaN with it.
|
||||
return useMemo(
|
||||
() => getEffectiveTimelineDuration(timelineDuration, timelineElements),
|
||||
[timelineDuration, timelineElements],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* dragged as well as while it is playing.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { liveTime, usePlayerStore } from "../player";
|
||||
// The store's own module, not the `player` barrel: the barrel pulls the whole
|
||||
// timeline in, and a timeline component importing this hook closes a cycle.
|
||||
import { liveTime, usePlayerStore } from "../player/store/playerStore";
|
||||
|
||||
/** Long enough to be much cheaper than a frame, short enough to read as motion. */
|
||||
const THROTTLE_MS = 33;
|
||||
|
||||
Reference in New Issue
Block a user