mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* 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>
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { useCallback, useRef } from "react";
|
|
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
|
|
// ── Helpers ──
|
|
|
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
return fields.map((field) => field.path).join(", ");
|
|
}
|
|
|
|
async function readErrorResponseBody(
|
|
response: Response,
|
|
): Promise<{ error?: string; fields?: string[] } | null> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("application/json")) return null;
|
|
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
|
}
|
|
|
|
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")
|
|
: [];
|
|
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
return `Couldn't save edit: ${body.error}${suffix}`;
|
|
}
|
|
|
|
interface RecordEditInput {
|
|
label: string;
|
|
kind: EditHistoryKind;
|
|
coalesceKey?: string;
|
|
files: Record<string, { before: string; after: string }>;
|
|
}
|
|
|
|
export interface UseDomEditCommitsParams {
|
|
activeCompPath: string | null;
|
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
fileTree: string[];
|
|
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
|
|
projectId: string | null;
|
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
reloadPreview: () => void;
|
|
|
|
// From useDomSelection
|
|
domEditSelection: DomEditSelection | null;
|
|
applyDomSelection: (
|
|
selection: DomEditSelection | null,
|
|
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
) => void;
|
|
clearDomSelection: () => void;
|
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
|
buildDomSelectionFromTarget: (
|
|
target: HTMLElement,
|
|
options?: { preferClipAncestor?: boolean },
|
|
) => 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({
|
|
activeCompPath,
|
|
previewIframeRef,
|
|
showToast,
|
|
queueDomEditSave,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
fileTree,
|
|
importedFontAssetsRef,
|
|
projectId,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
domEditSelection,
|
|
applyDomSelection,
|
|
clearDomSelection,
|
|
refreshDomEditSelectionFromPreview,
|
|
buildDomSelectionFromTarget,
|
|
onDomEditPersisted,
|
|
onElementDeleted,
|
|
}: UseDomEditCommitsParams) {
|
|
const resolveImportedFontAsset = useCallback(
|
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
if (!family) return null;
|
|
const imported = importedFontAssetsRef.current.find(
|
|
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
);
|
|
if (imported) return imported;
|
|
const asset = fileTree.find(
|
|
(path) =>
|
|
FONT_EXT.test(path) &&
|
|
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
);
|
|
if (!asset) return null;
|
|
return {
|
|
family: fontFamilyFromAssetPath(asset),
|
|
path: asset,
|
|
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
};
|
|
},
|
|
[fileTree, projectId, importedFontAssetsRef],
|
|
);
|
|
|
|
const reportedUnresolvableRef = useRef(new Set<string>());
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (selection, operations, options) => {
|
|
const pid = projectIdRef.current;
|
|
if (!pid) throw new Error("No active project");
|
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
|
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
|
|
const readResponse = await fetch(
|
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
);
|
|
if (!readResponse.ok) {
|
|
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
|
|
}
|
|
const readData = (await readResponse.json()) as { content?: string };
|
|
const originalContent = readData.content;
|
|
if (typeof originalContent !== "string") {
|
|
throw new Error(`Missing file contents for ${targetPath}`);
|
|
}
|
|
|
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
|
|
const patchTarget = buildDomEditPatchTarget(selection);
|
|
const patchBody = { target: patchTarget, operations };
|
|
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
if (unsafeFields.length > 0) {
|
|
const fields = formatUnsafeFieldList(unsafeFields);
|
|
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
throw new Error(`DOM patch contains unsafe values: ${fields}`);
|
|
}
|
|
|
|
// Mark the save timestamp before the file write so the SSE file-change
|
|
// handler suppresses the reload even if the event arrives before the
|
|
// response (the server writes the file and emits SSE during the fetch).
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
|
|
const patchResponse = await fetch(
|
|
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patchBody),
|
|
},
|
|
);
|
|
if (!patchResponse.ok) {
|
|
showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
|
|
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
|
|
}
|
|
|
|
const patchData = (await patchResponse.json()) as {
|
|
ok?: boolean;
|
|
changed?: boolean;
|
|
matched?: boolean;
|
|
content?: string;
|
|
};
|
|
|
|
if (!patchData.changed) {
|
|
if (patchData.matched === false) {
|
|
const targetKey = selection.selector ?? selection.id ?? "selection";
|
|
if (!reportedUnresolvableRef.current.has(targetKey)) {
|
|
reportedUnresolvableRef.current.add(targetKey);
|
|
trackStudioEvent("save_skipped_unresolvable", {
|
|
target_id: selection.id ?? undefined,
|
|
target_selector: selection.selector ?? undefined,
|
|
target_source_file: selection.sourceFile ?? undefined,
|
|
composition: activeCompPath ?? undefined,
|
|
});
|
|
console.warn(
|
|
`[studio] Element not found in source: ${targetKey}. ` +
|
|
"This element may be generated at runtime and cannot be persisted.",
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const patchedContent =
|
|
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
|
|
let finalContent = patchedContent;
|
|
if (options?.prepareContent) {
|
|
finalContent = options.prepareContent(patchedContent, targetPath);
|
|
if (finalContent !== patchedContent) {
|
|
await writeProjectFile(targetPath, finalContent);
|
|
}
|
|
}
|
|
|
|
await editHistory.recordEdit({
|
|
label: options?.label ?? "Edit layer",
|
|
kind: "manual",
|
|
coalesceKey: options?.coalesceKey,
|
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
});
|
|
onDomEditPersisted?.(selection, operations);
|
|
|
|
if (!options?.skipRefresh) {
|
|
reloadPreview();
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
editHistory,
|
|
writeProjectFile,
|
|
projectIdRef,
|
|
domEditSaveTimestampRef,
|
|
reloadPreview,
|
|
showToast,
|
|
onDomEditPersisted,
|
|
],
|
|
);
|
|
|
|
// ── Text & style commits (delegated to useDomEditTextCommits) ──
|
|
|
|
const {
|
|
handleDomStyleCommit,
|
|
handleDomAttributeCommit,
|
|
handleDomHtmlAttributeCommit,
|
|
handleDomTextCommit,
|
|
commitDomTextFields,
|
|
handleDomTextFieldStyleCommit,
|
|
handleDomAddTextField,
|
|
handleDomRemoveTextField,
|
|
} = useDomEditTextCommits({
|
|
activeCompPath,
|
|
previewIframeRef,
|
|
domEditSelection,
|
|
applyDomSelection,
|
|
refreshDomEditSelectionFromPreview,
|
|
buildDomSelectionFromTarget,
|
|
persistDomEditOperations,
|
|
resolveImportedFontAsset,
|
|
});
|
|
|
|
// ── Position patch helper (shared by geometry + lifecycle hooks) ──
|
|
|
|
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
|
|
activeCompPath,
|
|
persistDomEditOperations,
|
|
queueDomEditSave,
|
|
showToast,
|
|
});
|
|
|
|
// ── Geometry commits (path offset, box size, rotation) ──
|
|
|
|
const {
|
|
handleDomPathOffsetCommit,
|
|
handleDomGroupPathOffsetCommit,
|
|
handleDomBoxSizeCommit,
|
|
handleDomRotationCommit,
|
|
handleDomManualEditsReset,
|
|
} = useDomGeometryCommits({
|
|
previewIframeRef,
|
|
showToast,
|
|
commitPositionPatchToHtml,
|
|
});
|
|
|
|
// ── Element lifecycle (delete, z-index reorder) ──
|
|
|
|
const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
|
activeCompPath,
|
|
showToast,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
clearDomSelection,
|
|
commitPositionPatchToHtml,
|
|
onElementDeleted,
|
|
});
|
|
|
|
return {
|
|
resolveImportedFontAsset,
|
|
handleDomStyleCommit,
|
|
handleDomAttributeCommit,
|
|
handleDomHtmlAttributeCommit,
|
|
handleDomTextCommit,
|
|
commitDomTextFields,
|
|
handleDomTextFieldStyleCommit,
|
|
handleDomAddTextField,
|
|
handleDomRemoveTextField,
|
|
handleDomPathOffsetCommit,
|
|
handleDomGroupPathOffsetCommit,
|
|
handleDomBoxSizeCommit,
|
|
handleDomRotationCommit,
|
|
handleDomManualEditsReset,
|
|
handleDomEditElementDelete,
|
|
handleDomZIndexReorderCommit,
|
|
};
|
|
}
|