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:
Vance Ingalls
2026-06-15 18:08:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d48369c76
commit 8f15e9f09b
12 changed files with 398 additions and 26 deletions
@@ -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(