Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const sdkSession = useSdkSession(projectId, activeCompPath);
const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html");
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// fallow-ignore-file complexity
import { useCallback, useRef } from "react";
import type { Composition } from "@hyperframes/sdk";
import { runShadowTiming } from "../utils/sdkShadow";
import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
Expand Down Expand Up @@ -288,6 +288,7 @@ export function useTimelineEditing({
);
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
if (sdkSession) runShadowDelete(sdkSession, element.hfId);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
Expand All @@ -303,6 +304,7 @@ export function useTimelineEditing({
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
sdkSession,
],
);

Expand Down
44 changes: 44 additions & 0 deletions packages/studio/src/utils/sdkShadow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ describe("sdkShadowDispatch (integration)", () => {
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
});

it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);

const ops: PatchOperation[] = [
{ type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);

expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
});

it("returns dispatched:false when hfId not found in session", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
Expand Down Expand Up @@ -143,6 +157,21 @@ describe("sdkShadowDispatch (integration)", () => {
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
});

// fallow-ignore-next-line code-duplication
it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);

// path-offset drags emit these already-data-prefixed, SDK-excluded markers.
const ops: PatchOperation[] = [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);

expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
});

it("returns dispatch_error when dispatch throws — does not propagate", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
Expand Down Expand Up @@ -301,6 +330,21 @@ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
});

it("matches the same element across different selector forms when a resolver is given", () => {
// SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
const sdk = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const server = `var tl = gsap.timeline({ paused: true });
tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
// Without a resolver: selector-form divergence → present/absent mismatch.
expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
// With a resolver: matched by element → no mismatch.
expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
});
});

describe("runShadowGsapFidelity", () => {
Expand Down
38 changes: 32 additions & 6 deletions packages/studio/src/utils/sdkShadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ import type { PatchOperation } from "./sourcePatcher";
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
// (e.g. "name"); prefix only when needed, never double-prefix.
function attrName(property: string): string {
return property.startsWith("data-") ? property : `data-${property}`;
}

// The SDK element model excludes data-hf-* attributes (document.ts skips them),
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
// never match — drop those ops from the shadow instead of false-mismatching.
function isShadowableOp(op: PatchOperation): boolean {
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
return true;
}

export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
Expand All @@ -38,7 +54,7 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO
result.push({
type: "setAttribute",
target: hfId,
name: `data-${op.property}`,
name: attrName(op.property),
value: op.value,
});
} else if (op.type === "html-attribute") {
Expand Down Expand Up @@ -98,17 +114,24 @@ function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {

type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;

// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
// style properties are kebab-case ("background-color"). Convert for read-back, else
// every hyphenated property false-mismatches against a null actual.
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}

const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
"inline-style": (op, flat) => ({
property: op.property,
expected: op.value,
actual: flat.styles[op.property] ?? null,
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
}),
"text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
attribute: (op, flat) => ({
property: `data-${op.property}`,
property: attrName(op.property),
expected: op.value ?? null,
actual: flat.attrs[`data-${op.property}`] ?? null,
actual: flat.attrs[attrName(op.property)] ?? null,
}),
"html-attribute": (op, flat) => ({
property: op.property,
Expand Down Expand Up @@ -157,8 +180,11 @@ export function sdkShadowDispatch(
if (!session.getElement(hfId)) {
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
}
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
const shadowable = ops.filter(isShadowableOp);
try {
const sdkOps = patchOpsToSdkEditOps(hfId, ops);
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of sdkOps) session.dispatch(op);
});
Expand All @@ -169,7 +195,7 @@ export function sdkShadowDispatch(
};
}
const flat = flattenSnapshot(session.getElement(hfId));
const mismatches = ops
const mismatches = shadowable
.map((op) => checkOpParity(op, flat, hfId))
.filter((m): m is SdkShadowMismatch => m !== null);
return { dispatched: true, mismatches };
Expand Down
83 changes: 65 additions & 18 deletions packages/studio/src/utils/sdkShadowGsapFidelity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,28 @@ function extractGsapScript(html: string): string | null {
return null;
}

function animById(script: string): Map<string, GsapAnimation> {
function posKey(position: unknown): string {
if (typeof position === "number") return String(position);
const n = Number(position);
return Number.isNaN(n) ? String(position) : String(n);
}

// Key a tween by its RESOLVED target element (not raw selector) + method +
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
// emits class/other selectors for the SAME element; keying by resolved element
// matches them so the diff compares values instead of flagging present/absent.
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
return `${sel}|${anim.method}|${posKey(anim.position)}`;
}

function animByKey(
script: string,
resolveSelector?: (sel: string) => string,
): Map<string, GsapAnimation> {
const map = new Map<string, GsapAnimation>();
const parsed = parseGsapScriptAcorn(script);
for (const anim of parsed.animations) map.set(anim.id, anim);
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim);
return map;
}

Expand Down Expand Up @@ -73,37 +91,41 @@ function canonicalProps(obj: Record<string, unknown> | undefined): string {
}

/**
* Structurally diff two GSAP scripts by tween id. Reports a tween present in
* one but not the other, and per-field value drift (method, position, duration,
* ease, properties, fromProperties). Comparison is canonical (see above) so
* writer formatting differences do not produce false mismatches.
* Structurally diff two GSAP scripts. Tweens are matched by resolved target
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
* selectors and the server's class selectors for the same element don't
* false-flag present/absent. Reports a tween present in one but not the other,
* and per-field value drift (duration, ease, properties, fromProperties).
* Comparison is canonical so writer formatting differences don't register.
*
* Pass resolveSelector (selector → canonical element id) to enable the
* element-based matching; without it, matching falls back to raw selector.
*/
// fallow-ignore-next-line complexity
export function gsapFidelityMismatches(
sdkScript: string,
serverScript: string,
resolveSelector?: (sel: string) => string,
): SdkShadowMismatch[] {
const sdk = animById(sdkScript);
const server = animById(serverScript);
const sdk = animByKey(sdkScript, resolveSelector);
const server = animByKey(serverScript, resolveSelector);
const mismatches: SdkShadowMismatch[] = [];
const ids = new Set([...sdk.keys(), ...server.keys()]);
for (const id of ids) {
const a = sdk.get(id);
const b = server.get(id);
const keys = new Set([...sdk.keys(), ...server.keys()]);
for (const key of keys) {
const a = sdk.get(key);
const b = server.get(key);
if (!a || !b) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
hfId: key,
property: "tween",
expected: b ? "present" : "absent",
actual: a ? "present" : "absent",
});
continue;
}
// [property, sdk-value, server-value, equal?]
// method + position are part of the key (already equal); compare values.
const fields: Array<[string, unknown, unknown, boolean]> = [
["method", a.method, b.method, a.method === b.method],
["position", a.position, b.position, numericEqual(a.position, b.position)],
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
["ease", a.ease, b.ease, a.ease === b.ease],
[
Expand All @@ -123,7 +145,7 @@ export function gsapFidelityMismatches(
if (!equal) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
hfId: key,
property,
expected: bv == null ? null : JSON.stringify(bv),
actual: av == null ? null : JSON.stringify(av),
Expand Down Expand Up @@ -158,6 +180,27 @@ export function resolveGsapFidelityArgs(
return { before, op: shadowGsapOp, serverScript };
}

// Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op
// document, so tweens that target the same element via different selectors
// ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw
// selector when it can't resolve (DOMParser unavailable, no match, bad selector).
function makeSelectorResolver(html: string): (sel: string) => string {
let doc: Document | null = null;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
doc = null;
}
return (sel) => {
if (!doc) return sel;
try {
return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel;
} catch {
return sel;
}
};
}

/**
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
Expand Down Expand Up @@ -189,7 +232,11 @@ export async function runShadowGsapFidelity(
});
return;
}
const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
const mismatches = gsapFidelityMismatches(
sdkScript,
serverScript,
makeSelectorResolver(beforeHtml),
);
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_fidelity",
dispatched: true,
Expand Down
Loading