mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): route GSAP keyframe add through SDK (§3.5 PR2) (#1470)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
592f7c775d
commit
7ca4490328
@@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => {
|
||||
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => {
|
||||
// 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity.
|
||||
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 });
|
||||
expect(result).toContain("opacity: 0.7");
|
||||
expect(result).toContain("x: 100");
|
||||
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("removeKeyframeFromScript removes the target percentage", () => {
|
||||
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
|
||||
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
|
||||
|
||||
@@ -774,7 +774,17 @@ export function addKeyframeToScript(
|
||||
// Emit exactly one overwrite per changed node, plus one insert for a new key.
|
||||
const ms = new MagicString(src);
|
||||
if (existing) {
|
||||
ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord));
|
||||
// Merge into the existing keyframe at this percentage, preserving sibling
|
||||
// properties — overwrite only the given keys. (A whole-value overwrite here
|
||||
// would silently drop other properties already keyframed at this percent.)
|
||||
if (existing.prop.value?.type === "ObjectExpression") {
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
upsertProp(ms, existing.prop.value, k, v);
|
||||
}
|
||||
if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease);
|
||||
} else {
|
||||
ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord));
|
||||
}
|
||||
} else {
|
||||
insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||
import { sdkGsapKeyframePersist } from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
||||
@@ -10,6 +12,7 @@ import type {
|
||||
SafeGsapCommitMutation,
|
||||
TrackGsapSaveFailure,
|
||||
} from "./gsapScriptCommitTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
function executeOptimisticKeyframeCacheUpdate(options: {
|
||||
sourceFile: string;
|
||||
@@ -30,7 +33,22 @@ function executeOptimisticKeyframeCacheUpdate(options: {
|
||||
});
|
||||
}
|
||||
|
||||
interface GsapKeyframeOpsParams {
|
||||
interface SdkKeyframeDeps {
|
||||
sdkSession?: Composition | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
editHistory?: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
reloadPreview?: () => void;
|
||||
domEditSaveTimestampRef?: React.MutableRefObject<number>;
|
||||
}
|
||||
|
||||
interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
|
||||
activeCompPath: string | null;
|
||||
commitMutation: CommitMutation;
|
||||
commitMutationSafely: SafeGsapCommitMutation;
|
||||
@@ -42,6 +60,11 @@ export function useGsapKeyframeOps({
|
||||
commitMutation,
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
}: GsapKeyframeOpsParams) {
|
||||
const addKeyframe = useCallback(
|
||||
(
|
||||
@@ -67,36 +90,97 @@ export function useGsapKeyframeOps({
|
||||
(a, b) => a.percentage - b.percentage,
|
||||
),
|
||||
}),
|
||||
persist: () =>
|
||||
commitMutation(selection, mutation, {
|
||||
persist: async () => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const handled = await sdkGsapKeyframePersist(
|
||||
sourceFile,
|
||||
animationId,
|
||||
percentage,
|
||||
{ [property]: value },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
{
|
||||
label: `Add keyframe at ${percentage}%`,
|
||||
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
|
||||
},
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
await commitMutation(selection, mutation, {
|
||||
label: `Add keyframe at ${percentage}%`,
|
||||
softReload: true,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}).catch((error) => {
|
||||
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
||||
});
|
||||
},
|
||||
[activeCompPath, commitMutation, trackGsapSaveFailure],
|
||||
[
|
||||
activeCompPath,
|
||||
commitMutation,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
);
|
||||
|
||||
const addKeyframeBatch = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapKeyframePersist(
|
||||
sourceFile,
|
||||
animationId,
|
||||
percentage,
|
||||
properties,
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
{ label: `Add keyframe at ${percentage}%` },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "add-keyframe", animationId, percentage, properties },
|
||||
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
[
|
||||
commitMutation,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
);
|
||||
|
||||
const removeKeyframe = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
||||
// ponytail: SDK removeGsapKeyframe uses keyframeIndex (not percentage); mismatch with
|
||||
// Studio's percentage-based API. Resolving index requires parsing GSAP state at call
|
||||
// time — deferred. removeKeyframe stays server-authoritative.
|
||||
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
||||
const mutation = { type: "remove-keyframe", animationId, percentage };
|
||||
void executeOptimisticKeyframeCacheUpdate({
|
||||
@@ -126,6 +210,7 @@ export function useGsapKeyframeOps({
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => {
|
||||
// ponytail: no SDK equivalent; convertToKeyframes stays server-authoritative (T6f scope)
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
|
||||
@@ -137,6 +222,7 @@ export function useGsapKeyframeOps({
|
||||
|
||||
const removeAllKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
// ponytail: no SDK equivalent for remove-all-keyframes; stays server-authoritative
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
{ type: "remove-all-keyframes", animationId },
|
||||
|
||||
@@ -118,7 +118,17 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
});
|
||||
const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
|
||||
const keyframeOps = useGsapKeyframeOps({
|
||||
activeCompPath,
|
||||
commitMutation,
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
});
|
||||
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
||||
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
sdkDeletePersist,
|
||||
sdkTimingPersist,
|
||||
sdkGsapTweenPersist,
|
||||
sdkGsapKeyframePersist,
|
||||
} from "./sdkCutover";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||
@@ -556,6 +557,71 @@ describe("sdkGsapTweenPersist", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkGsapKeyframePersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
const makeSession = () =>
|
||||
({
|
||||
dispatch: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
}) as unknown as Parameters<typeof sdkGsapKeyframePersist>[4];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
expect(
|
||||
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches addGsapKeyframe and writes serialized content", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
const result = await sdkGsapKeyframePersist(
|
||||
"/comp.html",
|
||||
"tw-1",
|
||||
50,
|
||||
{ opacity: 0.5 },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "addGsapKeyframe",
|
||||
animationId: "tw-1",
|
||||
position: 50,
|
||||
value: { opacity: 0.5 },
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and does not write on dispatch error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("dispatch failed");
|
||||
});
|
||||
const result = await sdkGsapKeyframePersist(
|
||||
"/comp.html",
|
||||
"tw-1",
|
||||
25,
|
||||
{ x: 100 },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
|
||||
@@ -189,6 +189,28 @@ export async function sdkGsapTweenPersist(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sdkGsapKeyframePersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
position: number,
|
||||
value: Record<string, unknown>,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
if (!sdkSession) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value });
|
||||
await persistSdkSerialize(sdkSession, targetPath, before, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sdkDeletePersist(
|
||||
hfId: string,
|
||||
originalContent: string,
|
||||
|
||||
Reference in New Issue
Block a user