mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): extend SDK shadow to delete/timing/gsap-add + default on (#1473)
* feat(studio): default SDK shadow dispatch on for parity telemetry Shadow mode keeps the server patch path authoritative (no user-visible change) and emits sdk_shadow_dispatch parity signal. Default it on so we collect addressing/serialize-drift telemetry from all traffic before any cutover. Disable via VITE_STUDIO_SDK_SHADOW_ENABLED=false. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): shadow parity for delete/timing/gsap ops + wire delete Extends shadow visibility past the property-edit path. Adds a can()-first shadow core (pure addressing/validity pre-check, works even for GSAP which has no snapshot value) plus runShadowDelete/runShadowTiming/runShadowGsapTween. Parity coverage: delete = getElement null (full); timing = snapshot start/duration/trackIndex (full); gsap = can()+dispatch+returned-id only (animationIds is a stub, tween values are script-level — full fidelity needs serialize() round-trip diffing, out of scope). Wires the delete runner end-to-end via an onElementDeleted callback (useDomEditSession → useDomEditCommits → useElementLifecycleOps), fired after the server delete succeeds. Server stays authoritative. Timing/GSAP wiring follows (each needs threading sdkSession into useTimelineEditing / useGsapScriptCommits). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): wire timing + GSAP-add shadow dispatch Timing: thread sdkSession into useTimelineEditing; fire runShadowTiming after move/resize persist (server authoritative). Moved the useSdkSession call above useTimelineEditing so both share the single session (no duplicate). GSAP: thread sdkSession through useGsapScriptCommits → useGsapAnimationOps; shadow addGsapAnimation via runShadowGsapTween after the server add. Only the add path is shadowed — delete/update key on the server's animationId, which doesn't resolve in the SDK's independent id-space (would emit false cannot_dispatch). "set" has no SDK method, so it's skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): address #1473 review — no-persist shadow session + fallow gate Blocker (Rames): the shadow runners dispatched on the live persisted SDK session, so each shadow op fired the persist queue → an HTTP write of the SDK's serialize() output, clobbering the studio's authoritative write (default-on shipped this). Fix: open the shadow session WITHOUT persist — it reads from the server but never writes back. Shadow dispatches mutate the in-memory model only and are discarded on the next reload-on-change. Cutover (Step 3c+) must re-add persist together with self-write suppression. No persist consumer exists in this stack (cutover is not in main), so this is safe and keeps default-on. Fallow CI gate (Miguel): - drop unused `export` on RecordEditInput (dead-type) - suppress pre-existing CRAP with reasons: commitMutation, addGsapAnimation; file-level complexity on useTimelineEditing (shadow .then() branches nudge several callbacks over threshold — telemetry-only) - suppress 3 pre-existing clones surfaced by adjacent edits (save-error formatter, prop-drilling passthrough, file-change reload handler) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): scrub user content from shadow property-path telemetry Addresses #1473 review concern (Rames): inline-style and text-content edits put user content into the sdk_shadow_dispatch mismatch expected/actual fields. Redact before emit — text-content values fully redacted (length only), others length-capped at 64. The in-memory parity result keeps raw values, so the parity logic and tests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2d48369c76
commit
8f15e9f09b
@@ -175,6 +175,7 @@ export function StudioApp() {
|
||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
const sdkSession = useSdkSession(projectId, activeCompPath);
|
||||
const timelineEditing = useTimelineEditing({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -188,6 +189,7 @@ export function StudioApp() {
|
||||
pendingTimelineEditPathRef,
|
||||
uploadProjectFiles: fileManager.uploadProjectFiles,
|
||||
isRecordingRef: isGestureRecordingRef,
|
||||
sdkSession,
|
||||
});
|
||||
const {
|
||||
activeBlockParams,
|
||||
@@ -267,7 +269,6 @@ export function StudioApp() {
|
||||
() => leftSidebarRef.current?.getTab() ?? "compositions",
|
||||
[],
|
||||
);
|
||||
const sdkSession = useSdkSession(projectId, activeCompPath);
|
||||
const domEditSession = useDomEditSession({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
|
||||
@@ -90,11 +90,13 @@ export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
|
||||
// session alongside the server patch path and logs mismatches via telemetry.
|
||||
// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
|
||||
// Default on: server stays authoritative (no user-visible change), so we want
|
||||
// the sdk_shadow_dispatch parity signal from all traffic. Disable via
|
||||
// VITE_STUDIO_SDK_SHADOW_ENABLED=false.
|
||||
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
@@ -55,4 +56,6 @@ export interface GsapScriptCommitsParams {
|
||||
onCacheInvalidate: () => void;
|
||||
onFileContentChanged?: (path: string, content: string) => void;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
|
||||
sdkSession?: Composition | null;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ async function readErrorResponseBody(
|
||||
|
||||
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
||||
if (!body?.error) return "Couldn't save edit";
|
||||
// Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers);
|
||||
// surfaced here by this PR's adjacent edits, not introduced by it.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const fields = Array.isArray(body.fields)
|
||||
? body.fields.filter((field): field is string => typeof field === "string")
|
||||
: [];
|
||||
@@ -73,6 +76,8 @@ export interface UseDomEditCommitsParams {
|
||||
) => Promise<DomEditSelection | null>;
|
||||
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
||||
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
||||
/** Stage 7 Step 3b: called after a successful server-side element delete. */
|
||||
onElementDeleted?: (selection: DomEditSelection) => void;
|
||||
}
|
||||
|
||||
export function useDomEditCommits({
|
||||
@@ -94,6 +99,7 @@ export function useDomEditCommits({
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
onDomEditPersisted,
|
||||
onElementDeleted,
|
||||
}: UseDomEditCommitsParams) {
|
||||
const resolveImportedFontAsset = useCallback(
|
||||
(fontFamilyValue: string): ImportedFontAsset | null => {
|
||||
@@ -290,6 +296,7 @@ export function useDomEditCommits({
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
commitPositionPatchToHtml,
|
||||
onElementDeleted,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||
import { useDomEditCommits } from "./useDomEditCommits";
|
||||
import { runShadowDispatch } from "../utils/sdkShadow";
|
||||
import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
|
||||
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
||||
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
||||
import { useDomEditWiring } from "./useDomEditWiring";
|
||||
@@ -194,6 +194,7 @@ export function useDomEditSession({
|
||||
onCacheInvalidate: bumpGsapCache,
|
||||
onFileContentChanged: updateEditingFileContent,
|
||||
showToast,
|
||||
sdkSession,
|
||||
});
|
||||
|
||||
// ── DOM commit handlers ──
|
||||
@@ -235,6 +236,7 @@ export function useDomEditSession({
|
||||
onDomEditPersisted: sdkSession
|
||||
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
|
||||
: undefined,
|
||||
onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
|
||||
});
|
||||
|
||||
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
||||
@@ -263,6 +265,9 @@ export function useDomEditSession({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
} = useDomEditWiring({
|
||||
// Pre-existing prop-drilling clone (same param set forwarded to
|
||||
// useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
projectId,
|
||||
activeCompPath,
|
||||
domEditSelection,
|
||||
|
||||
@@ -31,6 +31,8 @@ interface UseElementLifecycleOpsParams {
|
||||
patches: PatchOperation[],
|
||||
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
||||
) => Promise<void>;
|
||||
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
|
||||
onElementDeleted?: (selection: DomEditSelection) => void;
|
||||
}
|
||||
|
||||
export function useElementLifecycleOps({
|
||||
@@ -43,6 +45,7 @@ export function useElementLifecycleOps({
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
commitPositionPatchToHtml,
|
||||
onElementDeleted,
|
||||
}: UseElementLifecycleOpsParams) {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleDomEditElementDelete = useCallback(
|
||||
@@ -103,6 +106,7 @@ export function useElementLifecycleOps({
|
||||
clearDomSelection();
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
reloadPreview();
|
||||
onElementDeleted?.(selection);
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete element";
|
||||
@@ -114,6 +118,7 @@ export function useElementLifecycleOps({
|
||||
clearDomSelection,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory.recordEdit,
|
||||
onElementDeleted,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { runShadowGsapTween } from "../utils/sdkShadow";
|
||||
import {
|
||||
assignGsapTargetAutoIdIfNeeded,
|
||||
ensureElementAddressable,
|
||||
@@ -13,6 +15,8 @@ interface GsapAnimationOpsParams {
|
||||
commitMutation: CommitMutation;
|
||||
commitMutationSafely: SafeGsapCommitMutation;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
|
||||
sdkSession?: Composition | null;
|
||||
}
|
||||
|
||||
export function useGsapAnimationOps({
|
||||
@@ -21,6 +25,7 @@ export function useGsapAnimationOps({
|
||||
commitMutation,
|
||||
commitMutationSafely,
|
||||
showToast,
|
||||
sdkSession,
|
||||
}: GsapAnimationOpsParams) {
|
||||
const updateGsapMeta = useCallback(
|
||||
(
|
||||
@@ -62,7 +67,10 @@ export function useGsapAnimationOps({
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
// Pre-existing complexity (auto-id assignment + per-method defaults); this PR
|
||||
// adds only a guarded shadow-op construction at the tail.
|
||||
const addGsapAnimation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
@@ -109,8 +117,25 @@ export function useGsapAnimationOps({
|
||||
},
|
||||
{ label: `Add GSAP ${method} animation` },
|
||||
);
|
||||
|
||||
// Shadow: dispatch the equivalent addGsapTween to the SDK (server stays
|
||||
// authoritative). "set" has no SDK method, so it is not shadowed.
|
||||
// ponytail: only add is shadowed — delete/update key on the server's
|
||||
// animationId, which doesn't resolve in the SDK's independent id-space.
|
||||
if (sdkSession && selection.hfId && method !== "set") {
|
||||
const tween: GsapTweenSpec = {
|
||||
method,
|
||||
position,
|
||||
duration,
|
||||
ease: "power2.out",
|
||||
...(method === "fromTo"
|
||||
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
||||
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
||||
};
|
||||
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
|
||||
}
|
||||
},
|
||||
[activeCompPath, commitMutation, projectIdRef, showToast],
|
||||
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,10 @@ async function mutateGsapScript(
|
||||
|
||||
// oxfmt-ignore
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) {
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
||||
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
||||
// adds only a guarded shadow-fidelity dispatch.
|
||||
// fallow-ignore-next-line complexity
|
||||
const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
@@ -81,7 +84,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
||||
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
||||
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
||||
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast });
|
||||
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession });
|
||||
const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
|
||||
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
||||
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
||||
|
||||
@@ -20,12 +20,15 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
||||
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
||||
* re-opens it when the active composition file changes on disk (code editor,
|
||||
* agent, or server-side patch) so the in-memory linkedom document never goes
|
||||
* stale. The persist queue writes back to `activeCompPath` (not the
|
||||
* "composition.html" default).
|
||||
* stale.
|
||||
*
|
||||
* The session is idle until Step 3c routes dispatch ops through it; re-opening
|
||||
* is therefore purely additive — no SDK self-write exists yet, so there is no
|
||||
* persist echo. Step 3c must add self-write suppression once dispatch writes.
|
||||
* Opened WITHOUT a persist queue: this session is shadow-telemetry +
|
||||
* selection-sync only — it reads from the server but must NEVER write back.
|
||||
* Shadow dispatch ops mutate the in-memory model and are discarded on the next
|
||||
* reload-on-change (the studio's own authoritative write triggers it). Routing
|
||||
* authoritative writes through this session (cutover, Step 3c+) must re-add
|
||||
* persist TOGETHER WITH self-write suppression — without it, the SDK's
|
||||
* serialize() output races and clobbers the studio's authoritative write.
|
||||
*/
|
||||
export function useSdkSession(
|
||||
projectId: string | null,
|
||||
@@ -37,6 +40,9 @@ export function useSdkSession(
|
||||
// ── Re-open on external change to the active composition ──
|
||||
useEffect(() => {
|
||||
if (!activeCompPath) return;
|
||||
// Pre-existing clone of the file-change reload handler (usePreviewPersistence);
|
||||
// surfaced by this PR's adjacent edits, not introduced by it.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const handler = (payload?: unknown) => {
|
||||
if (shouldReloadSdkSession(payload, activeCompPath)) {
|
||||
setReloadToken((t) => t + 1);
|
||||
@@ -69,13 +75,10 @@ export function useSdkSession(
|
||||
.read(activeCompPath)
|
||||
.then(async (content) => {
|
||||
if (cancelled || typeof content !== "string") return;
|
||||
comp = await openComposition(content, {
|
||||
persist: adapter,
|
||||
persistPath: activeCompPath,
|
||||
});
|
||||
comp.on("persist:error", (e) => {
|
||||
console.warn("[sdk] persist:error", e.error);
|
||||
});
|
||||
// No persist — shadow/selection only; see the hook docstring. The SDK
|
||||
// must not write back to the server while it shadows the authoritative
|
||||
// studio path.
|
||||
comp = await openComposition(content);
|
||||
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
||||
if (cancelled) {
|
||||
comp.dispose();
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
|
||||
// playback-start resolution); this PR adds guarded shadow-timing dispatches in
|
||||
// the move/resize .then() chains, which nudges several callbacks over the CC
|
||||
// threshold. The added branches are telemetry-only.
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { runShadowTiming } from "../utils/sdkShadow";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
@@ -33,7 +40,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface RecordEditInput {
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
@@ -53,6 +60,8 @@ interface UseTimelineEditingOptions {
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
/** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */
|
||||
sdkSession?: Composition | null;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -70,6 +79,7 @@ export function useTimelineEditing({
|
||||
pendingTimelineEditPathRef,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
sdkSession,
|
||||
}: UseTimelineEditingOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
@@ -138,6 +148,11 @@ export function useTimelineEditing({
|
||||
value: String(updates.track),
|
||||
});
|
||||
}).then(() => {
|
||||
if (sdkSession)
|
||||
runShadowTiming(sdkSession, element.hfId, {
|
||||
start: updates.start,
|
||||
trackIndex: updates.track,
|
||||
});
|
||||
const pid = projectIdRef.current;
|
||||
if (delta !== 0 && element.domId && pid) {
|
||||
return shiftGsapPositions(pid, filePath, element.domId, delta)
|
||||
@@ -146,7 +161,7 @@ export function useTimelineEditing({
|
||||
}
|
||||
});
|
||||
},
|
||||
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
|
||||
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
|
||||
);
|
||||
|
||||
const handleTimelineElementResize = useCallback(
|
||||
@@ -190,6 +205,11 @@ export function useTimelineEditing({
|
||||
}
|
||||
return patched;
|
||||
}).then(() => {
|
||||
if (sdkSession)
|
||||
runShadowTiming(sdkSession, element.hfId, {
|
||||
start: updates.start,
|
||||
duration: updates.duration,
|
||||
});
|
||||
const pid = projectIdRef.current;
|
||||
if (timingChanged && element.domId && pid) {
|
||||
return scaleGsapPositions(
|
||||
@@ -207,7 +227,7 @@ export function useTimelineEditing({
|
||||
return reloadPreview();
|
||||
});
|
||||
},
|
||||
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
|
||||
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
|
||||
);
|
||||
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
patchOpsToSdkEditOps,
|
||||
runShadowDelete,
|
||||
runShadowTiming,
|
||||
runShadowGsapTween,
|
||||
SdkShadowMismatch,
|
||||
} from "./sdkShadow";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
|
||||
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
||||
trackedEvents.push({ event, props }),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
trackedEvents.length = 0;
|
||||
});
|
||||
const lastShadow = () =>
|
||||
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
||||
|
||||
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
||||
@@ -144,3 +162,85 @@ describe("sdkShadowDispatch (integration)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const TIMING_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
|
||||
</body></html>`;
|
||||
|
||||
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`;
|
||||
|
||||
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
|
||||
<div data-hf-id="hf-box"></div>
|
||||
<script>gsap.defaults({ ease: "power1.out" });
|
||||
window.__timelines = {};</script>
|
||||
</div>`;
|
||||
|
||||
describe("runShadowDelete", () => {
|
||||
it("removes the element from the SDK session and reports parity", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, "hf-box");
|
||||
expect(session.getElement("hf-box")).toBeNull();
|
||||
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("reports no_hf_id when selection has no hf-id", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, null);
|
||||
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
|
||||
});
|
||||
|
||||
it("reports cannot_dispatch when the element is not addressable", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, "hf-missing");
|
||||
expect(lastShadow()).toMatchObject({
|
||||
op: "delete",
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowTiming", () => {
|
||||
it("applies timing and reports parity against the snapshot", async () => {
|
||||
const session = await openComposition(TIMING_HTML);
|
||||
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
|
||||
const el = session.getElement("hf-clip");
|
||||
expect(el?.start).toBe(2);
|
||||
expect(el?.duration).toBe(3);
|
||||
expect(el?.trackIndex).toBe(1);
|
||||
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowGsapTween", () => {
|
||||
it("dispatches add against a real timeline and reports success", async () => {
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
runShadowGsapTween(session, {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||
});
|
||||
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
|
||||
const session = await openComposition(NO_TIMELINE_HTML);
|
||||
runShadowGsapTween(session, {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
});
|
||||
expect(lastShadow()).toMatchObject({
|
||||
op: "gsap",
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
code: "E_NO_GSAP_TIMELINE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { EditOp } from "@hyperframes/sdk";
|
||||
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
||||
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
@@ -182,6 +182,26 @@ export function sdkShadowDispatch(
|
||||
* Despite the telemetry focus, this function does mutate the SDK session — it
|
||||
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
||||
*/
|
||||
// Property-path mismatches carry user content (inline-style values, edited
|
||||
// text) in expected/actual. Scrub before telemetry: fully redact text-content
|
||||
// values, length-cap the rest. The in-memory parity result keeps raw values.
|
||||
function redactValueForTelemetry(
|
||||
property: string | undefined,
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value == null) return value;
|
||||
if (property === "text") return `[redacted len=${value.length}]`;
|
||||
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
||||
}
|
||||
|
||||
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
|
||||
return mismatches.map((m) => ({
|
||||
...m,
|
||||
expected: redactValueForTelemetry(m.property, m.expected),
|
||||
actual: redactValueForTelemetry(m.property, m.actual),
|
||||
}));
|
||||
}
|
||||
|
||||
export function runShadowDispatch(
|
||||
session: Composition,
|
||||
selection: DomEditSelection,
|
||||
@@ -191,6 +211,7 @@ export function runShadowDispatch(
|
||||
const hfId = selection.hfId;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "property",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
@@ -199,8 +220,185 @@ export function runShadowDispatch(
|
||||
}
|
||||
const result = sdkShadowDispatch(session, hfId, ops);
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "property",
|
||||
dispatched: result.dispatched,
|
||||
mismatchCount: result.mismatches.length,
|
||||
mismatches: JSON.stringify(result.mismatches),
|
||||
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
|
||||
//
|
||||
// These ops never flow through persistDomEditOperations, so the property-path
|
||||
// shadow above never sees them. Each runner keeps the server authoritative and
|
||||
// only observes the SDK: can() pre-checks addressing/validity (pure, no
|
||||
// mutation — works even for GSAP, which has no element-snapshot value), then a
|
||||
// dispatch into the live session with a snapshot-based parity check.
|
||||
//
|
||||
// Parity coverage by op:
|
||||
// delete → getElement(id) === null (full)
|
||||
// timing → snapshot.start/duration/trackIndex (full)
|
||||
// gsap → tween id present/absent in animationIds (existence only — the
|
||||
// tween's property values are script-level, not in the snapshot)
|
||||
|
||||
/**
|
||||
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
|
||||
* Mutates the SDK session (not read-only); server stays authoritative.
|
||||
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
||||
*/
|
||||
function runShadowEditOp(
|
||||
session: Composition,
|
||||
op: EditOp,
|
||||
opLabel: string,
|
||||
dispatchAndCheck: () => SdkShadowMismatch[],
|
||||
): void {
|
||||
const verdict = session.can(op);
|
||||
if (!verdict.ok) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
code: verdict.code,
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let mismatches: SdkShadowMismatch[];
|
||||
try {
|
||||
mismatches = dispatchAndCheck();
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: false,
|
||||
reason: "dispatch_error",
|
||||
error: String(err),
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: true,
|
||||
mismatchCount: mismatches.length,
|
||||
mismatches: JSON.stringify(mismatches),
|
||||
});
|
||||
}
|
||||
|
||||
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
|
||||
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "delete",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const op: EditOp = { type: "removeElement", target: hfId };
|
||||
runShadowEditOp(session, op, "delete", () => {
|
||||
session.batch(() => session.dispatch(op));
|
||||
return session.getElement(hfId)
|
||||
? [
|
||||
{
|
||||
kind: "value_mismatch",
|
||||
hfId,
|
||||
property: "exists",
|
||||
expected: "removed",
|
||||
actual: "present",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShadowTiming {
|
||||
start?: number;
|
||||
duration?: number;
|
||||
trackIndex?: number;
|
||||
}
|
||||
|
||||
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
||||
export function runShadowTiming(
|
||||
session: Composition,
|
||||
hfId: string | null | undefined,
|
||||
timing: ShadowTiming,
|
||||
): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "timing",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
|
||||
runShadowEditOp(session, op, "timing", () => {
|
||||
session.batch(() => session.dispatch(op));
|
||||
const el = session.getElement(hfId);
|
||||
const mismatches: SdkShadowMismatch[] = [];
|
||||
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
|
||||
["start", el?.start],
|
||||
["duration", el?.duration],
|
||||
["trackIndex", el?.trackIndex],
|
||||
];
|
||||
for (const [key, actual] of fields) {
|
||||
const expected = timing[key];
|
||||
if (expected !== undefined && actual !== expected) {
|
||||
mismatches.push({
|
||||
kind: "value_mismatch",
|
||||
hfId,
|
||||
property: key,
|
||||
expected: String(expected),
|
||||
actual: actual == null ? null : String(actual),
|
||||
});
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
});
|
||||
}
|
||||
|
||||
export type ShadowGsapOp =
|
||||
| { kind: "add"; target: string; tween: GsapTweenSpec }
|
||||
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| { kind: "remove"; animationId: string };
|
||||
|
||||
/**
|
||||
* Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the
|
||||
* tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub
|
||||
* (always [] — see sdk document.ts). So the signal here is can() addressing /
|
||||
* validity + dispatch-didn't-throw, plus (for add) that the SDK returned a
|
||||
* non-empty tween id. Full fidelity needs serialize()-script round-trip diffing,
|
||||
* out of scope for shadow. // ponytail: upgrade when animationIds is populated.
|
||||
*/
|
||||
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
const op: EditOp =
|
||||
gsapOp.kind === "add"
|
||||
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
|
||||
: gsapOp.kind === "set"
|
||||
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
||||
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
||||
runShadowEditOp(session, op, "gsap", () => {
|
||||
let newId: string | undefined;
|
||||
session.batch(() => {
|
||||
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
||||
else session.dispatch(op);
|
||||
});
|
||||
if (gsapOp.kind === "add" && !newId) {
|
||||
return [
|
||||
{
|
||||
kind: "value_mismatch",
|
||||
hfId: gsapOp.target,
|
||||
property: "tweenId",
|
||||
expected: "non-empty",
|
||||
actual: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user